36 Commits

Author SHA1 Message Date
Hadi ed59923b7d v0.0.4
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 00:03:37 +02:00
Hadi aa7b639f82 Add pre-commit hook for vendorHash validation #7
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 00:02:21 +02:00
Hadi 27e0c418e9 Add project name in sidebar
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:52:20 +02:00
Hadi 08757a5d1d Remove some keybindings in short help
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:51:54 +02:00
Hadi ffee0978e6 move legal disclaimer 2026-05-18 23:47:00 +02:00
Hadi 4f45a7c061 Add request/history specific regex
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:44:50 +02:00
Hadi 0fafa52c65 Update deps
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:42:29 +02:00
Hadi 366bb682d2 Edit readme
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:51 +02:00
Hadi 9fe0c74150 Edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:46 +02:00
Hadi 3b6b58ac2b Change paginator dots when no entry
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:33:30 +02:00
Hadi 789a513469 add "add-default-config" flag
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:31:49 +02:00
Hadi 615093bd8b Add the config option "keep responses"
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:12:01 +02:00
Hadi 7e1b7d3b5a add warning for CA
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 23:05:15 +02:00
Hadi e3e89582c1 Add commit hook & markdown scripts 2026-05-18 22:59:48 +02:00
Hadi 2705c2882d Change flag.Usage
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 22:32:12 +02:00
Hadi 6ea692754a move docs to root
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:50:32 +02:00
Hadi 85c2806604 Add search in ui/docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:22:17 +02:00
Hadi d451965fa0 Add disclaimer & vim-like nav
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 21:17:35 +02:00
Hadi d82a220e91 add extra space when icons are not rendered
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:40:28 +02:00
Hadi 1ac5eb26e8 Change popup width/height
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:34:52 +02:00
Hadi 969febb14c Change help menus: Only display shortcuts used on the page
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-18 20:23:56 +02:00
Hadi 6aa377acd8 add demo
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 22:31:29 +02:00
Hadi 2f4765bf37 Add asciimoji
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 22:26:26 +02:00
Hadi fac335a16e Add Installation instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 20:41:53 +02:00
Hadi 407ca13a33 v0.0.3
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:20:57 +02:00
Hadi 9ab7f12bf4 gofmt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:20:33 +02:00
Hadi 7d4f32549e update vendor hash
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:19:28 +02:00
Hadi bed122fc99 Merge pull request #1 from anotherhadi/dependabot/go_modules/github.com/sirupsen/logrus-1.8.3
Bump github.com/sirupsen/logrus from 1.8.1 to 1.8.3
2026-05-13 18:17:05 +02:00
Hadi 967aab8363 Edit issue templates: md -> yaml, init plugin_request
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:15:42 +02:00
Hadi a414a51168 Update docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:11:47 +02:00
Hadi c7392474b7 CopyRequest -> Copy & CopyAs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 18:07:23 +02:00
Hadi de254b4e52 Use local timezone for findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:36:46 +02:00
Hadi 47d2cf6845 change default config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:04:06 +02:00
Hadi 26994a3a37 upstream proxy
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 17:03:00 +02:00
Hadi 4eb9dd53f5 Change plugins behavior
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-13 16:52:12 +02:00
dependabot[bot] 4caecaeec4 Bump github.com/sirupsen/logrus from 1.8.1 to 1.8.3
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.8.1 to 1.8.3.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.8.1...v1.8.3)

---
updated-dependencies:
- dependency-name: github.com/sirupsen/logrus
  dependency-version: 1.8.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:50:45 +00:00
77 changed files with 2264 additions and 578 deletions
-28
View File
@@ -1,28 +0,0 @@
---
name: "🐞 Bug Report"
about: Report a reproducible error
title: "[BUG] "
labels: bug
---
**Describe the bug**
A clear and concise description of the issue.
**To Reproduce**
1. Go to '...'
2. Click on '....'
3. See error
**Expected Behavior**
What should have happened.
**Environment**
- OS:
- Version (`spilltea -v`):
**Additional Context**
Add any logs or screenshots here.
+57
View File
@@ -0,0 +1,57 @@
name: "🐞 Bug Report"
description: Report a reproducible error
title: "[BUG] "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of the issue.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Step-by-step instructions to trigger the bug.
placeholder: |
1. ...
2. ...
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened instead.
validations:
required: true
- type: input
id: os
attributes:
label: Operating system
placeholder: "e.g. Ubuntu 24.04, macOS 15"
validations:
required: true
- type: input
id: version
attributes:
label: spilltea version
description: Output of `spilltea -v`
placeholder: "e.g. v0.3.1"
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Logs, screenshots, or anything else relevant.
validations:
required: false
-22
View File
@@ -1,22 +0,0 @@
---
name: "🚀 Feature Request"
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
---
**Is your feature request related to a problem?**
A description of what the problem is (e.g. I'm always frustrated when...).
**Describe the solution**
A clear description of what you want to happen.
**Describe alternatives**
Any alternative solutions or features you've considered.
**Additional context**
Add any other context or mockups here.
@@ -0,0 +1,37 @@
name: "🚀 Feature Request"
description: Suggest an idea for this project
title: "[FEATURE] "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: Is your feature request related to a problem? Describe it.
placeholder: "I'm always frustrated when..."
validations:
required: false
- type: textarea
id: solution
attributes:
label: Proposed solution
description: A clear description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any alternative solutions or features you've thought about.
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Mockups, related issues, or anything else relevant.
validations:
required: false
+52
View File
@@ -0,0 +1,52 @@
name: "🧩 Plugin Request"
description: Suggest a plugin idea or propose an existing plugin
title: "[PLUGIN] "
labels: ["plugin"]
body:
- type: dropdown
id: request_type
attributes:
label: Request type
description: Are you proposing an idea or an already-built plugin?
options:
- "Plugin idea — I have an idea but haven't built it"
- "Plugin proposal — I have already built a plugin"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: >
**If this is an idea:** describe what the plugin would do and the problem it solves.
**If this is a proposal:** describe what your plugin does and link to its repository.
placeholder: "This plugin would..."
validations:
required: true
- type: input
id: repo
attributes:
label: Repository (proposals only)
description: Link to the plugin repository, if it exists.
placeholder: "https://github.com/..."
validations:
required: false
- type: textarea
id: use_case
attributes:
label: Use case
description: Who would benefit from this plugin and in what scenario?
placeholder: "Useful when testing APIs that..."
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional context
description: Screenshots, mockups, related issues, or anything else relevant.
validations:
required: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

+41
View File
@@ -0,0 +1,41 @@
Output ./.github/assets/demo.gif
Require spilltea
Set Shell "zsh"
Set FontSize 32
Set Width 1600
Set Height 1900
Type "spilltea"
Sleep 800ms
Enter
Sleep 3s
Down@800ms 2
Up@800ms 1
Enter@800ms 1
Wait+Screen /hadi.icu/
Sleep 3s
Ctrl+Y
Sleep 3s
Down@1.9 4
Escape@1.3 1
Ctrl+R
Sleep 3s
Type "e"
Sleep 1s
Escape
Sleep 1s
Type "s"
Sleep 6s
Type "1"
Sleep 2s
Type "f"
Sleep 2s
Type "2"
Sleep 2s
-127
View File
@@ -1,127 +0,0 @@
# 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.
-48
View File
@@ -1,48 +0,0 @@
-- 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
@@ -1,35 +0,0 @@
-- 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
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
go mod vendor
COMPUTED_HASH=$(nix hash path vendor/)
rm -rf vendor/
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
echo "vendorHash is up to date"
exit 0
fi
echo "Updating vendorHash in flake.nix..."
python3 -c "
import sys
with open('flake.nix', 'r') as f:
content = f.read()
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
with open('flake.nix', 'w') as f:
f.write(content)
"
echo " Old: $CURRENT_HASH"
echo " New: $COMPUTED_HASH"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import re
import subprocess
import sys
from pathlib import Path
PATTERN = re.compile(r"<!-- exec: (.+?) -->.*?<!-- endexec -->", re.DOTALL)
def replace(match):
cmd = match.group(1).strip()
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
output = result.stdout
if result.returncode != 0:
print(f"[inject-exec] command failed ({result.returncode}): {cmd}", file=sys.stderr)
print(result.stderr, file=sys.stderr)
sys.exit(1)
output = re.sub(r"<!-- exec: .+? -->\n?|<!-- endexec -->\n?", "", output)
if output and not output.endswith("\n"):
output += "\n"
return f"<!-- exec: {cmd} -->\n{output}<!-- endexec -->"
def process(path):
content = Path(path).read_text()
new_content = PATTERN.sub(replace, content)
if new_content != content:
Path(path).write_text(new_content)
print(f"[inject-exec] updated {path}")
for p in sys.argv[1:]:
process(p)
+88 -12
View File
@@ -14,23 +14,92 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![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) [![Go Report Card](https://goreportcard.com/badge/github.com/anotherhadi/spilltea)](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [What is Spilltea?](#what-is-spilltea)
- [Legal Disclaimer](#legal-disclaimer)
- [Features](#features)
- [Installation](#installation)
- [Project Management](#project-management)
- [Configuration](#configuration)
- [CLI Flags](#cli-flags)
- [Plugin System](#plugin-system)
- [Deployment](#deployment)
- [Tech Stack](#tech-stack)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## What is 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. 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. It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, keyboard-driven tool that gets out of your way.
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
<!-- exec: cat ./docs/legal-disclaimer.md -->
## Legal Disclaimer
**This tool is provided for educational purposes and authorized security testing only.**
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
<!-- endexec -->
## Features ## Features
- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding. - **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. - **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 - **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
- **HTTPS Support** (using go-mitmproxy under the hood) - **HTTPS Support** (using go-mitmproxy under the hood)
- **Vim-like Navigation**: The entire interface is keyboard-driven with Vim-inspired shortcuts. Use `h/j/k/l` to move, `gg`/`G` to jump to the top/bottom, `/` to search, `q` to close panels, and more. All keybindings are fully customizable via the config file.
- Built-in Integrations: - Built-in Integrations:
- **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly. - **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. - **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. - **Markdown Export**: Export any request and its response as a clean Markdown snippet, ready to drop into a report.
## Installation
<details>
<summary>Go install</summary>
```sh
go install github.com/anotherhadi/spilltea/cmd/spilltea@latest
```
Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
</details>
<details>
<summary>Nix (temporary run, no install)</summary>
```sh
nix run github:anotherhadi/spilltea
```
</details>
<details>
<summary>NixOS (flake)</summary>
Add spilltea to your flake inputs:
```nix
inputs.spilltea.url = "github:anotherhadi/spilltea";
```
Then add the package to your system or home-manager packages:
```nix
environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ];
```
</details>
<!-- exec: cat ./docs/basics.md -->
## Project Management ## 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. Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
@@ -41,11 +110,6 @@ On startup, you choose:
- **Existing project**: pick from a list of previous projects - **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! - **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 ## Configuration
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`. Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
@@ -53,13 +117,25 @@ Check the default configuration with all the options [here](./internal/config/de
## CLI Flags ## CLI Flags
| Flag | Short | Description | ```
| ---- | ----- | ----------- | Usage: spilltea [flags]
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
| `--host` | | Proxy host, overrides config | --add-default-config copy the default config file to the config path and exit
| `--port` | `-p` | Proxy port, overrides config | --add-default-plugins copy built-in example plugins into the plugins dir and exit
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session | -c, --config string path to config file
| `--version` | `-v` | Print version and exit | --host string proxy host (overrides config)
--plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version print version
```
<!-- endexec -->
## 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](./docs/plugins.md) or [plugin examples](./plugins/).
## Deployment ## Deployment
+51
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
spilltea "github.com/anotherhadi/spilltea"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
@@ -23,11 +24,20 @@ var version = "dev"
func main() { func main() {
var ( var (
flagConfig = flag.StringP("config", "c", "", "path to config file") flagConfig = flag.StringP("config", "c", "", "path to config file")
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
flagHost = flag.String("host", "", "proxy host (overrides config)") flagHost = flag.String("host", "", "proxy host (overrides config)")
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)") flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
flagVersion = flag.BoolP("version", "v", false, "print version") flagVersion = flag.BoolP("version", "v", false, "print version")
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`) flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
flagAddDefaultConfig = flag.Bool("add-default-config", false, "copy the default config file to the config path and exit")
) )
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = func() {
fmt.Println("Usage: spilltea [flags]\n")
flag.PrintDefaults()
}
flag.Parse() flag.Parse()
if *flagVersion { if *flagVersion {
@@ -35,6 +45,41 @@ func main() {
os.Exit(0) os.Exit(0)
} }
if *flagAddDefaultPlugins {
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)
}
dir := config.ExpandPath(config.Global.App.PluginsDir)
if *flagPluginsDir != "" {
dir = *flagPluginsDir
}
n, err := spilltea.InstallDefaultPlugins(dir)
if err != nil {
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
os.Exit(1)
}
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
os.Exit(0)
}
if *flagAddDefaultConfig {
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
if *flagConfig != "" {
cfgPath = *flagConfig
}
if err := config.WriteDefaultConfig(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "add-default-config: %v\n", err)
os.Exit(1)
}
fmt.Printf("default config written to %s\n", cfgPath)
os.Exit(0)
}
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) { if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject) fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
os.Exit(1) os.Exit(1)
@@ -51,12 +96,18 @@ func main() {
} }
config.Global.Version = version config.Global.Version = version
if *flagPluginsDir != "" {
config.Global.App.PluginsDir = *flagPluginsDir
}
if *flagHost != "" { if *flagHost != "" {
config.Global.App.Host = *flagHost config.Global.App.Host = *flagHost
} }
if *flagPort != 0 { if *flagPort != 0 {
config.Global.App.Port = *flagPort config.Global.App.Port = *flagPort
} }
if *flagUpstreamProxy != "" {
config.Global.App.UpstreamProxy = *flagUpstreamProxy
}
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port) addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
// Check if the proxy port is available before starting the UI. // Check if the proxy port is available before starting the UI.
+1 -1
View File
@@ -2,5 +2,5 @@ package spilltea
import "embed" import "embed"
//go:embed .github/docs //go:embed docs
var DocsFS embed.FS var DocsFS embed.FS
+32
View File
@@ -0,0 +1,32 @@
## 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!
## 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)
## CLI Flags
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
```
Usage: spilltea [flags]
--add-default-config copy the default config file to the config path and exit
--add-default-plugins copy built-in example plugins into the plugins dir and exit
-c, --config string path to config file
--host string proxy host (overrides config)
--plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version print version
```
<!-- endexec -->
@@ -12,3 +12,6 @@
- Select the "Authorities" tab and click on "Import". - Select the "Authorities" tab and click on "Import".
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`. - 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". - When prompted, click the "Trust this CA to identify websites" checkbox, then click on "OK".
> [!WARNING]
> Never install this certificate in a permanent system trust store: it grants decryption of all HTTPS traffic. Remove it from your browser after use.
+7
View File
@@ -0,0 +1,7 @@
## Legal Disclaimer
**This tool is provided for educational purposes and authorized security testing only.**
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
+162
View File
@@ -0,0 +1,162 @@
# Plugins
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
You can found some pre-built plugins [here](../../plugins/).
## 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",
description = "What this plugin does.",
priority = 0, -- higher = runs before other plugins (default: 0)
-- Declare which hooks you use and whether they are synchronous (default: false).
-- on_config and on_quit are always sync and do not need to be declared here.
on_start = { sync = true },
on_request = { sync = true },
on_response = { sync = false },
on_history_entry = { sync = true },
}
```
### Hook reference
| Hook | When called | Sync/async | Return value (sync only) |
| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- |
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
| `on_start()` | Once at startup, after `on_config` | configurable | ignored |
| `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
## 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
-- kind is optional: "info" (default), "success", "warning", "error"
notif("Title", "Body text", "warning")
-- 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
})
-- Run a raw SQL query against the project DB (entries, findings, replay_entries, …)
-- Returns a table of rows; each row is a table indexed by column name.
-- Returns nil + error string on failure.
local rows, err = db_query("SELECT id, host FROM entries WHERE host = ?", "example.com")
if err then
log("query failed: " .. err)
else
for i = 1, #rows do
log(rows[i].host)
end
end
-- 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_config(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
## Sync vs async
- **`sync = true`**: spilltea waits for the hook to return before continuing. The hook can return a decision value (see below).
- **`sync = false`** (default for all configurable hooks): the hook runs in a background goroutine. Return values are ignored.
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
### Return values for sync hooks
**`on_request` and `on_response`:**
| 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. |
**`on_history_entry` (sync only):**
| Return value | Effect |
| ------------------- | -------------------------------------- |
| `"skip"` | The entry is not saved to the DB. |
| `"keep"` or `nil` | The entry is saved normally. |
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history. Async `on_history_entry` runs **after** the insert and cannot affect it.
## Priority
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
```lua
Plugin = {
name = "Scopes",
priority = 100, -- runs before all default-priority plugins
...
}
```
> A sync hook that hangs will block traffic for that flow. There is no automatic timeout.
+17 -2
View File
@@ -14,7 +14,7 @@
(system: f system (import nixpkgs {inherit system;})); (system: f system (import nixpkgs {inherit system;}));
pname = "spilltea"; pname = "spilltea";
version = "0.0.1"; version = "0.0.4";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
in { in {
@@ -25,7 +25,7 @@
src = ./.; src = ./.;
outputs = ["out"]; outputs = ["out"];
vendorHash = "sha256-u+u38Qr5Dugk5eFmxTK4vKUEv2SlXcfY6ZFlu1cPqVk="; vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players."; description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
@@ -37,5 +37,20 @@
"${pname}" = pkg; "${pname}" = pkg;
default = pkg; default = pkg;
}); });
devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
go
python3
lefthook
doctoc
];
shellHook = ''
lefthook install
'';
};
});
}; };
} }
+15 -16
View File
@@ -9,13 +9,13 @@ require (
charm.land/lipgloss/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/ansi v0.11.7
github.com/lqqyt2423/go-mitmproxy v1.8.11 github.com/lqqyt2423/go-mitmproxy v1.8.11
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.9.4
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.2
golang.org/x/net v0.39.0 golang.org/x/net v0.54.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.0 modernc.org/sqlite v1.50.1
) )
require ( require (
@@ -24,8 +24,8 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -33,38 +33,37 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/compress v1.17.8 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/satori/go.uuid v1.2.0 // 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/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.0 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
+40 -46
View File
@@ -24,14 +24,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= 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-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= 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/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 h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 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-20260517005351-920740d613be h1:O22D2Od8gEsRGTDPKDTRzx2BGrvVcIAJlwBf+1sTeN0=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/slice v0.0.0-20260517005351-920740d613be/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 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/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 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -50,10 +50,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -82,8 +82,8 @@ github.com/lqqyt2423/go-mitmproxy v1.8.11 h1:Au/qwhXSlKKCkDxPVa6aSfCJeoxoH6I+7zm
github.com/lqqyt2423/go-mitmproxy v1.8.11/go.mod h1:dSGnI17tVZ8dtYu9vnaIz7kxVwJNFH0CoNQwEQlTpxE= 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 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= 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/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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -92,8 +92,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -102,16 +102,14 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 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 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 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.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
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 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= 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 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -120,18 +118,16 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
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 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= 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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -140,29 +136,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 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/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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -171,18 +165,18 @@ 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/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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 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.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+12
View File
@@ -23,6 +23,7 @@ type Config struct {
CertDir string `mapstructure:"cert_dir"` CertDir string `mapstructure:"cert_dir"`
ProjectDir string `mapstructure:"project_dir"` ProjectDir string `mapstructure:"project_dir"`
PluginsDir string `mapstructure:"plugins_dir"` PluginsDir string `mapstructure:"plugins_dir"`
UpstreamProxy string `mapstructure:"upstream_proxy"`
} `mapstructure:"app"` } `mapstructure:"app"`
TUI struct { TUI struct {
@@ -44,6 +45,7 @@ type Config struct {
History struct { History struct {
SkipDuplicates bool `mapstructure:"skip_duplicates"` SkipDuplicates bool `mapstructure:"skip_duplicates"`
KeepResponses bool `mapstructure:"keep_responses"`
} `mapstructure:"history"` } `mapstructure:"history"`
Keybindings Keybindings `mapstructure:"keybindings"` Keybindings Keybindings `mapstructure:"keybindings"`
@@ -72,6 +74,16 @@ func Load(path string) error {
return viper.Unmarshal(Global) return viper.Unmarshal(Global)
} }
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func ExpandPath(p string) string { func ExpandPath(p string) string {
if strings.HasPrefix(p, "~/") { if strings.HasPrefix(p, "~/") {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
+14 -5
View File
@@ -4,6 +4,7 @@ app:
cert_dir: ~/.local/share/spilltea cert_dir: ~/.local/share/spilltea
project_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
@@ -12,13 +13,14 @@ intercept:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
replay: replay:
switch_to_page_on_send: false switch_to_page_on_send: true
history: history:
skip_duplicates: false # if true, skip saving entries with the same method, host, path and body skip_duplicates: true # if true, skip saving entries with the same method, host, path and body
keep_responses: false # if true, response body and headers are stored in history
tui: tui:
use_nerdfont_icons: true use_nerdfont_icons: false
default_sidebar_state: "expanded" # hidden, collapsed, expanded default_sidebar_state: "expanded" # hidden, collapsed, expanded
pretty_print_body: true # auto-indent JSON and HTML response bodies pretty_print_body: true # auto-indent JSON and HTML response bodies
colors: colors:
@@ -50,7 +52,8 @@ keybindings:
left: "left,h" left: "left,h"
right: "right,l" right: "right,l"
cycle_focus: "tab" cycle_focus: "tab"
copy_request: "ctrl+y" copy_as: "ctrl+y"
copy: "y"
send_to_replay: "ctrl+r" send_to_replay: "ctrl+r"
scroll_up: "pgup" scroll_up: "pgup"
scroll_down: "pgdown" scroll_down: "pgdown"
@@ -61,7 +64,7 @@ keybindings:
forward_all: "F" forward_all: "F"
drop: "d" drop: "d"
drop_all: "D" drop_all: "D"
toggle_intercept: "a" toggle_intercept: "i"
capture_response: "r" capture_response: "r"
undo_edits: "ctrl+z" undo_edits: "ctrl+z"
edit: "e,enter" edit: "e,enter"
@@ -96,3 +99,9 @@ keybindings:
toggle: "space" toggle: "space"
edit_config: "e,enter" edit_config: "e,enter"
filter: "/" filter: "/"
docs:
search: "/"
search_reset: "r"
search_next: "n"
search_prev: "N"
+10 -1
View File
@@ -10,7 +10,8 @@ type GlobalKeys struct {
Left string `mapstructure:"left"` Left string `mapstructure:"left"`
Right string `mapstructure:"right"` Right string `mapstructure:"right"`
CycleFocus string `mapstructure:"cycle_focus"` CycleFocus string `mapstructure:"cycle_focus"`
CopyRequest string `mapstructure:"copy_request"` CopyAs string `mapstructure:"copy_as"`
Copy string `mapstructure:"copy"`
SendToReplay string `mapstructure:"send_to_replay"` SendToReplay string `mapstructure:"send_to_replay"`
ScrollUp string `mapstructure:"scroll_up"` ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"` ScrollDown string `mapstructure:"scroll_down"`
@@ -65,6 +66,13 @@ type PluginsKeys struct {
Filter string `mapstructure:"filter"` Filter string `mapstructure:"filter"`
} }
type DocsKeys struct {
Search string `mapstructure:"search"`
SearchReset string `mapstructure:"search_reset"`
SearchNext string `mapstructure:"search_next"`
SearchPrev string `mapstructure:"search_prev"`
}
type Keybindings struct { type Keybindings struct {
Global GlobalKeys `mapstructure:"global"` Global GlobalKeys `mapstructure:"global"`
Intercept InterceptKeys `mapstructure:"intercept"` Intercept InterceptKeys `mapstructure:"intercept"`
@@ -74,4 +82,5 @@ type Keybindings struct {
Diff DiffKeys `mapstructure:"diff"` Diff DiffKeys `mapstructure:"diff"`
Findings FindingsKeys `mapstructure:"findings"` Findings FindingsKeys `mapstructure:"findings"`
Plugins PluginsKeys `mapstructure:"plugins"` Plugins PluginsKeys `mapstructure:"plugins"`
Docs DocsKeys `mapstructure:"docs"`
} }
+6
View File
@@ -68,6 +68,12 @@ CREATE TABLE IF NOT EXISTS replay_entries (
return err return err
} }
// Query executes a SQL query and returns the rows. The caller must close the
// returned rows. Args are passed as positional parameters.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.conn.Query(query, args...)
}
func (d *DB) Close() error { func (d *DB) Close() error {
if d == nil { if d == nil {
return nil return nil
+1 -1
View File
@@ -48,7 +48,7 @@ func (d *DB) LoadFindings() ([]Finding, error) {
} }
for _, layout := range findingTimeFormats { for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil { if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t f.CreatedAt = t.Local()
break break
} }
} }
+19 -3
View File
@@ -44,9 +44,14 @@ type Broker struct {
autoFwdMu sync.RWMutex autoFwdMu sync.RWMutex
autoFwdRegexes []*regexp.Regexp autoFwdRegexes []*regexp.Regexp
onBeforeNewEntry func(db.Entry) bool
onNewEntry func(db.Entry) onNewEntry func(db.Entry)
} }
func (b *Broker) SetOnBeforeNewEntry(cb func(db.Entry) bool) {
b.onBeforeNewEntry = cb
}
func (b *Broker) SetOnNewEntry(cb func(db.Entry)) { func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
b.onNewEntry = cb b.onNewEntry = cb
} }
@@ -165,15 +170,26 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
return return
} }
} }
entry, err := d.InsertEntry(db.Entry{ pending := db.Entry{
Timestamp: time.Now(), Timestamp: time.Now(),
Method: r.Method, Method: r.Method,
Host: r.URL.Host, Host: r.URL.Host,
Path: path, Path: path,
StatusCode: status, StatusCode: status,
RequestRaw: FormatRawRequest(f), RequestRaw: FormatRawRequest(f),
ResponseRaw: FormatRawResponse(f), ResponseRaw: func() string {
}) if config.Global.History.KeepResponses {
return FormatRawResponse(f)
}
return ""
}(),
}
if cb := b.onBeforeNewEntry; cb != nil {
if !cb(pending) {
return
}
}
entry, err := d.InsertEntry(pending)
if err == nil { if err == nil {
if cb := b.onNewEntry; cb != nil { if cb := b.onNewEntry; cb != nil {
go cb(entry) go cb(entry)
+26
View File
@@ -0,0 +1,26 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type DocsKeyMap struct {
Search key.Binding
SearchReset key.Binding
SearchNext key.Binding
SearchPrev key.Binding
}
func newDocsKeyMap(cfg config.DocsKeys) DocsKeyMap {
return DocsKeyMap{
Search: binding(cfg.Search, "search"),
SearchReset: binding(cfg.SearchReset, "reset search"),
SearchNext: binding(cfg.SearchNext, "next match"),
SearchPrev: binding(cfg.SearchPrev, "prev match"),
}
}
func (d DocsKeyMap) Bindings() []key.Binding {
return []key.Binding{d.Search, d.SearchReset, d.SearchNext, d.SearchPrev}
}
+10 -3
View File
@@ -15,7 +15,8 @@ type GlobalKeyMap struct {
Left key.Binding Left key.Binding
Right key.Binding Right key.Binding
CycleFocus key.Binding CycleFocus key.Binding
CopyRequest key.Binding CopyAs key.Binding
Copy key.Binding
Escape key.Binding Escape key.Binding
SendToReplay key.Binding SendToReplay key.Binding
ScrollUp key.Binding ScrollUp key.Binding
@@ -34,7 +35,8 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
Left: binding(cfg.Left, "scroll left"), Left: binding(cfg.Left, "scroll left"),
Right: binding(cfg.Right, "scroll right"), Right: binding(cfg.Right, "scroll right"),
CycleFocus: binding(cfg.CycleFocus, "cycle focus"), CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyRequest: binding(cfg.CopyRequest, "copy as..."), CopyAs: binding(cfg.CopyAs, "copy as..."),
Copy: binding(cfg.Copy, "copy..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
SendToReplay: binding(cfg.SendToReplay, "send to replay"), SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
@@ -47,8 +49,13 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
return []key.Binding{ return []key.Binding{
g.Up, g.Down, g.Left, g.Right, g.CycleFocus, g.Up, g.Down, g.Left, g.Right, g.CycleFocus,
g.Quit, g.Escape, g.Help, g.Quit, g.Escape, g.Help,
g.OpenLogs, g.ToggleSidebar, g.CopyRequest, g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
g.SendToReplay, g.SendToDiff, g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown, g.ScrollUp, g.ScrollDown,
} }
} }
// CommonBindings returns keys available on every page.
func (g GlobalKeyMap) CommonBindings() []key.Binding {
return []key.Binding{g.Quit, g.Help, g.OpenLogs, g.ToggleSidebar}
}
+2
View File
@@ -16,6 +16,7 @@ type KeyMap struct {
Diff DiffKeyMap Diff DiffKeyMap
Findings FindingsKeyMap Findings FindingsKeyMap
Plugins PluginsKeyMap Plugins PluginsKeyMap
Docs DocsKeyMap
} }
var Keys *KeyMap var Keys *KeyMap
@@ -31,6 +32,7 @@ func Init(cfg *config.Config) {
Diff: newDiffKeyMap(kb.Diff), Diff: newDiffKeyMap(kb.Diff),
Findings: newFindingsKeyMap(kb.Findings), Findings: newFindingsKeyMap(kb.Findings),
Plugins: newPluginsKeyMap(kb.Plugins), Plugins: newPluginsKeyMap(kb.Plugins),
Docs: newDocsKeyMap(kb.Docs),
} }
} }
+83 -2
View File
@@ -26,8 +26,9 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1) title := L.CheckString(1)
body := L.CheckString(2) body := L.CheckString(2)
kind := L.OptString(3, "info")
select { select {
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}: case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body, Kind: kind}:
default: default:
} }
return 0 return 0
@@ -64,7 +65,87 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0 return 0
})) }))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int { L.SetGlobal("db_query", L.NewFunction(func(L *lua.LState) int {
if mgr.db == nil {
L.Push(lua.LNil)
L.Push(lua.LString("db not available"))
return 2
}
query := L.CheckString(1)
var args []any
for i := 2; i <= L.GetTop(); i++ {
switch v := L.Get(i).(type) {
case lua.LString:
args = append(args, string(v))
case lua.LNumber:
args = append(args, float64(v))
case lua.LBool:
args = append(args, bool(v))
default:
args = append(args, nil)
}
}
rows, err := mgr.db.Query(query, args...)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
result := L.NewTable()
rowIdx := 1
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
row := L.NewTable()
for i, col := range cols {
switch v := vals[i].(type) {
case int64:
L.SetField(row, col, lua.LNumber(v))
case float64:
L.SetField(row, col, lua.LNumber(v))
case string:
L.SetField(row, col, lua.LString(v))
case []byte:
L.SetField(row, col, lua.LString(string(v)))
case bool:
if v {
L.SetField(row, col, lua.LTrue)
} else {
L.SetField(row, col, lua.LFalse)
}
case nil:
L.SetField(row, col, lua.LNil)
}
}
L.RawSetInt(result, rowIdx, row)
rowIdx++
}
if err := rows.Err(); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(result)
L.Push(lua.LNil)
return 2
}))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
reason := L.OptString(1, "plugin requested quit") reason := L.OptString(1, "plugin requested quit")
select { select {
case mgr.Quit <- reason: case mgr.Quit <- reason:
+90 -32
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
@@ -32,7 +33,8 @@ func NewManager(broker *intercept.Broker) *Manager {
Quit: make(chan string, 4), Quit: make(chan string, 4),
} }
if broker != nil { if broker != nil {
broker.SetOnNewEntry(mgr.RunOnHistoryEntry) broker.SetOnBeforeNewEntry(mgr.RunSyncOnHistoryEntry)
broker.SetOnNewEntry(mgr.RunAsyncOnHistoryEntry)
} }
return mgr return mgr
} }
@@ -107,27 +109,41 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua") p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
} }
// Defaults when not overridden by the Plugin table. if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
hookDefaults := map[string]bool{ p.Description = string(s)
"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 n, ok := pluginTable.RawGetString("priority").(lua.LNumber); ok {
if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" { p.Priority = int(n)
}
// Hooks configurable via the Plugin table (sync field).
configurableHooks := map[string]bool{
"on_start": false, // async by default
"on_request": false,
"on_response": false,
"on_history_entry": false,
}
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok { if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue} p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue continue
} }
}
// Auto-detect: register the hook if the function exists as a global.
if p.L.GetGlobal(hookName) != lua.LNil { if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: defaultSync} p.hooks[hookName] = HookConfig{Sync: defaultSync}
} }
} }
for hookName := range fixedSyncHooks {
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true}
}
}
return p, nil return p, nil
} }
@@ -137,6 +153,7 @@ func (m *Manager) GetPlugins() []*Plugin {
defer m.mu.RUnlock() defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins)) out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins) copy(out, m.plugins)
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
return out return out
} }
@@ -179,45 +196,61 @@ func (m *Manager) SaveConfig(name, configText string) {
found.mu.Lock() found.mu.Lock()
found.ConfigText = configText found.ConfigText = configText
enabled := found.Enabled enabled := found.Enabled
hc, hasOnStart := found.hooks["on_start"] _, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock() found.mu.Unlock()
if m.db != nil { if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText) _ = m.db.SavePluginState(name, enabled, configText)
} }
if !hasOnStart { if !hasOnConfig {
return return
} }
// Re-run on_start so the plugin can re-parse the new config. // on_config is always sync.
if hc.Sync {
found.mu.Lock() found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil { if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err) log.Printf("plugin %s on_config (config reload): %v", name, err)
} }
found.mu.Unlock() 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() { func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_start"]; !ok { if _, ok := p.hooks["on_config"]; !ok {
continue continue
} }
p.mu.Lock() p.mu.Lock()
if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil { if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_config: %v", p.Name, err)
}
p.mu.Unlock()
}
// on_start runs after, sync or async depending on plugin config.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_start"]
if !ok {
continue
}
if hc.Sync {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err) log.Printf("plugin %s on_start: %v", p.Name, err)
} }
p.mu.Unlock() p.mu.Unlock()
} else {
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
} }
} }
@@ -327,12 +360,37 @@ func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
} }
} }
func (m *Manager) RunOnHistoryEntry(e db.Entry) { // RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
for _, p := range m.GetPlugins() { for _, p := range m.GetPlugins() {
if !p.Enabled { if !p.Enabled {
continue continue
} }
if _, ok := p.hooks["on_history_entry"]; !ok { hc, ok := p.hooks["on_history_entry"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_history_entry", pushEntry(p.L, e))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue
}
if result == "skip" {
return false
}
}
return true
}
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue continue
} }
go func(p *Plugin) { go func(p *Plugin) {
+14 -2
View File
@@ -12,9 +12,11 @@ type HookConfig struct {
type Plugin struct { type Plugin struct {
Name string Name string
Description string
FilePath string FilePath string
Enabled bool Enabled bool
ConfigText string ConfigText string
Priority int
L *lua.LState L *lua.LState
mu sync.Mutex mu sync.Mutex
@@ -36,22 +38,31 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
type Info struct { type Info struct {
Name string Name string
Description string
FilePath string FilePath string
Enabled bool Enabled bool
ConfigText string ConfigText string
Priority int
Hooks map[string]HookConfig Hooks map[string]HookConfig
} }
func (p *Plugin) Info() Info { func (p *Plugin) Info() Info {
p.mu.Lock()
enabled := p.Enabled
configText := p.ConfigText
p.mu.Unlock()
hooks := make(map[string]HookConfig, len(p.hooks)) hooks := make(map[string]HookConfig, len(p.hooks))
for k, v := range p.hooks { for k, v := range p.hooks {
hooks[k] = v hooks[k] = v
} }
return Info{ return Info{
Name: p.Name, Name: p.Name,
Description: p.Description,
FilePath: p.FilePath, FilePath: p.FilePath,
Enabled: p.Enabled, Enabled: enabled,
ConfigText: p.ConfigText, ConfigText: configText,
Priority: p.Priority,
Hooks: hooks, Hooks: hooks,
} }
} }
@@ -59,6 +70,7 @@ func (p *Plugin) Info() Info {
type PluginNotifMsg struct { type PluginNotifMsg struct {
Title string Title string
Body string Body string
Kind string // "info", "success", "warning", "error"
} }
type PluginQuitMsg struct { type PluginQuitMsg struct {
+1
View File
@@ -108,6 +108,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
Addr: addr, Addr: addr,
StreamLargeBodies: 1024 * 1024 * 5, StreamLargeBodies: 1024 * 1024 * 5,
CaRootPath: caPath, CaRootPath: caPath,
Upstream: cfg.UpstreamProxy,
} }
p, err := goproxy.NewProxy(opts) p, err := goproxy.NewProxy(opts)
+7 -2
View File
@@ -28,15 +28,20 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
ta.Prompt = "" ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0 ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles() ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle() ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle() ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text) ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle) 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.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg) ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts) ta.SetStyles(ts)
return ta return ta
} }
+6
View File
@@ -24,6 +24,7 @@ type Styles struct {
Panel lipgloss.Style Panel lipgloss.Style
PanelFocused lipgloss.Style PanelFocused lipgloss.Style
PanelEditing lipgloss.Style
PagerDotActive string PagerDotActive string
PagerDotInactive string PagerDotInactive string
@@ -43,6 +44,7 @@ func Init(cfg *config.Config) {
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
S = &Styles{ S = &Styles{
Primary: primary, Primary: primary,
@@ -66,6 +68,10 @@ func Init(cfg *config.Config) {
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(primary), BorderForeground(primary),
PanelEditing: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(), PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(), PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
} }
+3
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -66,6 +67,7 @@ type Model struct {
pluginsPage pluginsUI.Model pluginsPage pluginsUI.Model
findingsPage findingsUI.Model findingsPage findingsUI.Model
copyAs copyasUI.Model copyAs copyasUI.Model
copy copyUI.Model
notifications notificationsUI.Model notifications notificationsUI.Model
} }
@@ -87,6 +89,7 @@ func New(broker *intercept.Broker, name, path string) Model {
pluginsPage: pluginsUI.New(mgr), pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(), findingsPage: findingsUI.New(),
copyAs: copyasUI.New(), copyAs: copyasUI.New(),
copy: copyUI.New(),
notifications: notificationsUI.New(), notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState), sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
} }
+1
View File
@@ -126,6 +126,7 @@ var pageRegistry = []pageEntry{
m.docs = updated.(docsUI.Model) m.docs = updated.(docsUI.Model)
return cmd return cmd
}, },
isEditing: func(m *Model) bool { return m.docs.IsEditing() },
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) }, resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
}, },
} }
+16 -1
View File
@@ -51,6 +51,7 @@ func (m *Model) renderSidebar() string {
titleText = "SPLT" titleText = "SPLT"
} }
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText) title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(s.Primary).Padding(0, 1).Render(titleText)
divider := strings.Repeat("─", inner) divider := strings.Repeat("─", inner)
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true) badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
@@ -75,14 +76,28 @@ func (m *Model) renderSidebar() string {
label += string(entry.id) label += string(entry.id)
} }
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label)) line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
if m.sidebarState == sidebarCollapsed && icon == "" {
line = " " + line
}
items.WriteString(line + "\n") items.WriteString(line + "\n")
} }
body := lipgloss.JoinVertical(lipgloss.Left, maxLen := inner - 2
name := m.projectName
if m.sidebarState == sidebarCollapsed && name == "temporary" {
name = "tmp"
} else if len(name) > maxLen {
name = name[:maxLen-1] + "…"
}
parts := []string{
title, title,
lipgloss.NewStyle().Width(inner).Foreground(s.Subtle).Padding(0, 1).Render(name),
}
parts = append(parts,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider), lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
items.String(), items.String(),
) )
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body) return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
} }
+62 -15
View File
@@ -13,6 +13,7 @@ import (
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins" "github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy" proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyUI "github.com/anotherhadi/spilltea/internal/ui/components/copy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas" copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications" notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
@@ -44,11 +45,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case plugins.PluginNotifMsg: case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager) cmd := plugins.WaitForNotif(m.pluginManager)
kind := notificationsUI.KindInfo
switch msg.Kind {
case "success":
kind = notificationsUI.KindSuccess
case "warning":
kind = notificationsUI.KindWarning
case "error":
kind = notificationsUI.KindError
}
notifCmd := func() tea.Msg { notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{ return notificationsUI.NotificationMsg{
Title: msg.Title, Title: msg.Title,
Body: msg.Body, Body: msg.Body,
Kind: notificationsUI.KindInfo, Kind: kind,
} }
} }
return m, tea.Batch(cmd, notifCmd) return m, tea.Batch(cmd, notifCmd)
@@ -72,6 +82,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
if m.copy.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copy.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copy, cmd = m.copy.Update(msg)
return m, cmd
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@@ -152,23 +175,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.activeIsEditing() { if !m.activeIsEditing() {
switch { switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest): case key.Matches(msg, keys.Keys.Global.CopyAs):
if m.page == pageDiff { var raw, scheme string
if raw := m.diff.CurrentRaw(); raw != "" { switch m.page {
m.copyAs.SetSize(m.width, m.height) case pageDiff:
m.copyAs.Open(copyasUI.OpenMsg{ raw = m.diff.CurrentRaw()
RawRequest: raw, scheme = "https"
Scheme: "https", case pageIntercept:
}) raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
} }
} else if m.page == pageIntercept { if raw != "" {
if raw := m.intercept.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height) m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{ m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
RawRequest: raw,
Scheme: m.intercept.CurrentScheme(),
})
} }
return m, nil
case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
}
if raw != "" {
m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
} }
return m, nil return m, nil
+7
View File
@@ -22,6 +22,13 @@ func (m Model) View() tea.View {
return v return v
} }
if m.copy.IsOpen() {
v := tea.NewView(m.copy.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal rendered := normal
if m.notifications.HasNotifications() { if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal) rendered = m.notifications.View(normal)
+176
View File
@@ -0,0 +1,176 @@
package copy
import (
"encoding/base64"
"fmt"
"os"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
const (
popupW = 55
popupH = 20
)
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 copyItem struct {
id string
title string
desc string
}
func (c copyItem) Title() string { return c.title }
func (c copyItem) Description() string { return c.desc }
func (c copyItem) FilterValue() string { return c.title }
var allItems = []list.Item{
copyItem{"raw", "Raw", "full HTTP request"},
copyItem{"headers", "Headers", "request headers only"},
copyItem{"body", "Body", "request body only"},
copyItem{"url", "URL", "request URL"},
}
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(allItems, delegate, popupW, 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(m.popupInnerWidth(), m.listHeight())
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
}
func (m Model) popupInnerWidth() int {
w := popupW
if m.width > 0 && m.width-4 < w {
w = m.width - 4
}
return w
}
func (m Model) popupHeight() int {
h := popupH
if m.height > 0 && m.height-4 < h {
h = m.height - 4
}
if h < 6 {
h = 6
}
return h
}
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
}
func (m Model) extract(id string) string {
raw := m.rawRequest
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
switch id {
case "raw":
return raw
case "headers":
var sb strings.Builder
for _, l := range lines[1:] {
if l == "" {
break
}
sb.WriteString(l + "\n")
}
return strings.TrimRight(sb.String(), "\n")
case "body":
for i, l := range lines {
if l == "" && i > 0 {
return strings.TrimRight(strings.Join(lines[i+1:], "\n"), "\n")
}
}
return ""
case "url":
scheme := m.scheme
if scheme == "" {
scheme = "https"
}
var host, path string
if len(lines) > 0 {
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 2 {
path = parts[1]
}
}
for _, l := range lines[1:] {
if l == "" {
break
}
if kv := strings.SplitN(l, ": ", 2); len(kv) == 2 && strings.EqualFold(kv[0], "host") {
host = strings.TrimSpace(kv[1])
}
}
return scheme + "://" + host + path
}
return raw
}
+30
View File
@@ -0,0 +1,30 @@
package copy
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().(copyItem); ok {
writeClipboard(m.extract(item.id))
}
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
}
+28
View File
@@ -0,0 +1,28 @@
package copy
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
)
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", inner, m.popupInnerWidth()+2, popupH)
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
}
+2
View File
@@ -66,6 +66,8 @@ func (pr parsedRequest) fullURL() string {
func formatAs(id, raw, scheme string) string { func formatAs(id, raw, scheme string) string {
pr := parseRaw(raw, scheme) pr := parseRaw(raw, scheme)
switch id { switch id {
case "raw":
return raw
case "curl": case "curl":
return toCurl(pr) return toCurl(pr)
case "python": case "python":
+17 -5
View File
@@ -11,7 +11,10 @@ import (
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
) )
const popupInnerW = 46 const (
popupW = 61
popupH = 20
)
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard. // writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…). // Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
@@ -36,6 +39,7 @@ func (f formatItem) Description() string { return f.desc }
func (f formatItem) FilterValue() string { return f.title } func (f formatItem) FilterValue() string { return f.title }
var allFormats = []list.Item{ var allFormats = []list.Item{
formatItem{"raw", "Raw", "raw HTTP request"},
formatItem{"curl", "cURL", "command line HTTP request"}, formatItem{"curl", "cURL", "command line HTTP request"},
formatItem{"python", "Python", "requests library"}, formatItem{"python", "Python", "requests library"},
formatItem{"go", "Go", "net/http package"}, formatItem{"go", "Go", "net/http package"},
@@ -68,7 +72,7 @@ func New() Model {
BorderForeground(s.Primary). BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1) Foreground(s.MutedFg).PaddingLeft(1)
l := list.New(allFormats, delegate, popupInnerW, 8) l := list.New(allFormats, delegate, popupW, 8)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetShowHelp(false) l.SetShowHelp(false)
@@ -91,17 +95,25 @@ func (m *Model) Open(msg OpenMsg) {
m.open = true m.open = true
m.list.ResetFilter() m.list.ResetFilter()
m.list.Select(0) m.list.Select(0)
m.list.SetSize(popupInnerW, m.listHeight()) m.list.SetSize(m.popupInnerWidth(), m.listHeight())
} }
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
m.width = w m.width = w
m.height = h m.height = h
m.list.SetSize(popupInnerW, m.listHeight()) m.list.SetSize(m.popupInnerWidth(), m.listHeight())
}
func (m Model) popupInnerWidth() int {
w := popupW
if m.width > 0 && m.width-4 < w {
w = m.width - 4
}
return w
} }
func (m Model) popupHeight() int { func (m Model) popupHeight() int {
h := 14 h := popupH
if m.height > 0 && m.height-4 < h { if m.height > 0 && m.height-4 < h {
h = m.height - 4 h = m.height - 4
} }
+3 -3
View File
@@ -24,12 +24,12 @@ func (m *Model) View(background string) string {
BorderForeground(s.Primary) BorderForeground(s.Primary)
popupH := m.popupHeight() popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH) popup := style.RenderWithTitle(border, "Copy as", inner, m.popupInnerWidth()+2, popupH)
return overlayCenter(background, popup, m.width, m.height) return OverlayCenter(background, popup, m.width, m.height)
} }
func overlayCenter(bg, popup string, w, h int) string { func OverlayCenter(bg, popup string, w, h int) string {
s := style.S s := style.S
stripped := ansi.Strip(bg) stripped := ansi.Strip(bg)
+8 -13
View File
@@ -221,16 +221,16 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
for i > 0 || j > 0 { for i > 0 || j > 0 {
switch { switch {
case i > 0 && j > 0 && a[i-1] == b[j-1]: case i > 0 && j > 0 && a[i-1] == b[j-1]:
left = append(left, diffLine{text: hlA(i-1), kind: lineUnchanged}) left = append(left, diffLine{text: hlA(i - 1), kind: lineUnchanged})
right = append(right, diffLine{text: hlB(j-1), kind: lineUnchanged}) right = append(right, diffLine{text: hlB(j - 1), kind: lineUnchanged})
i-- i--
j-- j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
left = append(left, diffLine{kind: lineAdded}) left = append(left, diffLine{kind: lineAdded})
right = append(right, diffLine{text: hlB(j-1), kind: lineAdded}) right = append(right, diffLine{text: hlB(j - 1), kind: lineAdded})
j-- j--
default: default:
left = append(left, diffLine{text: hlA(i-1), kind: lineRemoved}) left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved}) right = append(right, diffLine{kind: lineRemoved})
i-- i--
} }
@@ -243,14 +243,6 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
return left, right 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 } type diffKeyMap struct{ width int }
func (diffKeyMap) ShortHelp() []key.Binding { func (diffKeyMap) ShortHelp() []key.Binding {
@@ -259,6 +251,9 @@ func (diffKeyMap) ShortHelp() []key.Binding {
} }
func (m diffKeyMap) FullHelp() [][]key.Binding { func (m diffKeyMap) FullHelp() [][]key.Binding {
all := append(diffBindings(), keys.Keys.Global.Bindings()...) g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs}
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+253 -1
View File
@@ -1,36 +1,288 @@
package docs package docs
import ( import (
"regexp"
"sort"
"strings" "strings"
"unicode/utf8"
spilltea "github.com/anotherhadi/spilltea" spilltea "github.com/anotherhadi/spilltea"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
) )
func readDoc(name string) string { func readDoc(name string) string {
b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name) b, _ := spilltea.DocsFS.ReadFile("docs/" + name)
return string(b) return string(b)
} }
var contentMarkdown = strings.Join([]string{ var contentMarkdown = strings.Join([]string{
readDoc("main.md"), readDoc("main.md"),
readDoc("legal-disclaimer.md"),
readDoc("basics.md"),
readDoc("proxy.md"), readDoc("proxy.md"),
readDoc("certificate.md"), readDoc("certificate.md"),
readDoc("history.md"), readDoc("history.md"),
}, "\n") }, "\n")
type matchEntry struct {
line int
start int
end int
}
type Model struct { type Model struct {
viewport viewport.Model viewport viewport.Model
help help.Model
searchInput textinput.Model
searching bool
matches []matchEntry
matchIndex int
renderedLines []string
strippedLines []string
width int
height int
} }
func New() Model { func New() Model {
ti := textinput.New()
ti.Prompt = "/"
s := ti.Styles()
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
ti.SetStyles(s)
return Model{ return Model{
viewport: viewport.New(), viewport: viewport.New(),
help: style.NewHelp(),
searchInput: ti,
} }
} }
func (e Model) Init() tea.Cmd { func (e Model) Init() tea.Cmd {
return nil return nil
} }
func (m Model) IsEditing() bool { return m.searching }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.help.SetWidth(w - 2)
m.searchInput.SetWidth(w - 4)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
frameW := windowStyle().GetHorizontalFrameSize()
frameH := windowStyle().GetVerticalFrameSize()
m.viewport.SetWidth(w - frameW)
m.viewport.SetHeight(h - frameH - statusH)
m.renderMarkdown()
}
func (m *Model) applySearch() {
query := m.searchInput.Value()
m.matches = nil
m.matchIndex = 0
if query != "" {
re, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query))
if err == nil {
for lineIdx, stripped := range m.strippedLines {
for _, match := range re.FindAllStringIndex(stripped, -1) {
m.matches = append(m.matches, matchEntry{
line: lineIdx,
start: match[0],
end: match[1],
})
}
}
}
}
m.rebuildViewportContent()
if len(m.matches) > 0 {
m.viewport.SetYOffset(m.matches[0].line)
}
}
func (m *Model) searchNext() {
if len(m.matches) == 0 {
return
}
m.matchIndex = (m.matchIndex + 1) % len(m.matches)
m.rebuildViewportContent()
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
}
func (m *Model) searchPrev() {
if len(m.matches) == 0 {
return
}
m.matchIndex = (m.matchIndex - 1 + len(m.matches)) % len(m.matches)
m.rebuildViewportContent()
m.viewport.SetYOffset(m.matches[m.matchIndex].line)
}
func (m *Model) rebuildViewportContent() {
if len(m.matches) == 0 || m.searchInput.Value() == "" {
m.viewport.SetContent(strings.Join(m.renderedLines, "\n"))
return
}
type lineInfo struct {
intervals [][]int
currentIdx int
}
byLine := make(map[int]*lineInfo)
for i, match := range m.matches {
li := byLine[match.line]
if li == nil {
li = &lineInfo{currentIdx: -1}
byLine[match.line] = li
}
li.intervals = append(li.intervals, []int{match.start, match.end})
if i == m.matchIndex {
li.currentIdx = len(li.intervals) - 1
}
}
lines := make([]string, len(m.renderedLines))
for i, ansiLine := range m.renderedLines {
if li, ok := byLine[i]; ok {
lines[i] = injectHighlightsInLine(ansiLine, li.intervals, li.currentIdx)
} else {
lines[i] = ansiLine
}
}
m.viewport.SetContent(strings.Join(lines, "\n"))
}
func lipglossAnsiCodes(s lipgloss.Style) (open, close string) {
const sentinel = "X"
rendered := s.Render(sentinel)
idx := strings.Index(rendered, sentinel)
if idx < 0 {
return "", ""
}
return rendered[:idx], rendered[idx+len(sentinel):]
}
func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int) string {
if len(intervals) == 0 {
return ansiLine
}
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
type injection struct {
visPos int
code string
priority int // 0 = close (emit before opens at same pos), 1 = open
}
var injections []injection
for i, iv := range intervals {
open, close := normalOpen, normalClose
if i == currentIdx {
open, close = currentOpen, currentClose
}
injections = append(injections, injection{visPos: iv[0], code: open, priority: 1})
injections = append(injections, injection{visPos: iv[1], code: close, priority: 0})
}
sort.SliceStable(injections, func(a, b int) bool {
if injections[a].visPos != injections[b].visPos {
return injections[a].visPos < injections[b].visPos
}
return injections[a].priority < injections[b].priority
})
var sb strings.Builder
visPos := 0
injIdx := 0
i := 0
for i < len(ansiLine) {
for injIdx < len(injections) && injections[injIdx].visPos == visPos {
sb.WriteString(injections[injIdx].code)
injIdx++
}
if ansiLine[i] == '\x1b' {
j := i + 1
if j < len(ansiLine) {
switch ansiLine[j] {
case '[':
j++
for j < len(ansiLine) && (ansiLine[j] < '@' || ansiLine[j] > '~') {
j++
}
if j < len(ansiLine) {
j++
}
case ']':
j++
for j < len(ansiLine) {
if ansiLine[j] == '\a' {
j++
break
}
if ansiLine[j] == '\x1b' && j+1 < len(ansiLine) && ansiLine[j+1] == '\\' {
j += 2
break
}
j++
}
default:
j++
}
}
sb.WriteString(ansiLine[i:j])
i = j
} else {
_, size := utf8.DecodeRuneInString(ansiLine[i:])
if size == 0 {
size = 1
}
sb.WriteString(ansiLine[i : i+size])
i += size
visPos += size
}
}
for injIdx < len(injections) {
sb.WriteString(injections[injIdx].code)
injIdx++
}
return sb.String()
}
func (m *Model) renderStatusBar() string {
if m.searching {
return lipgloss.NewStyle().Padding(0, 1).Render(m.searchInput.View())
}
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(docsKeyMap{width: m.width}))
}
type docsKeyMap struct{ width int }
func (docsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
d := keys.Keys.Docs
return []key.Binding{g.Up, g.Down, d.Search, g.Help}
}
func (m docsKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
d := keys.Keys.Docs
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
all := append(d.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
}
+40 -9
View File
@@ -8,6 +8,8 @@ import (
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global g := keys.Keys.Global
d := keys.Keys.Docs
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg: case tea.MouseWheelMsg:
switch msg.Button { switch msg.Button {
@@ -18,7 +20,42 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.KeyPressMsg: case tea.KeyPressMsg:
if e.searching {
switch { switch {
case key.Matches(msg, d.SearchReset):
e.searching = false
e.searchInput.Blur()
e.searchInput.SetValue("")
e.matches = nil
e.matchIndex = 0
e.SetSize(e.width, e.height)
case msg.String() == "enter":
e.searching = false
e.searchInput.Blur()
e.SetSize(e.width, e.height)
default:
var cmd tea.Cmd
e.searchInput, cmd = e.searchInput.Update(msg)
e.applySearch()
return e, cmd
}
return e, nil
}
switch {
case key.Matches(msg, d.Search):
e.searching = true
e.searchInput.SetValue("")
e.searchInput.Focus()
e.SetSize(e.width, e.height)
case key.Matches(msg, d.SearchReset):
e.matches = nil
e.matchIndex = 0
e.rebuildViewportContent()
case key.Matches(msg, d.SearchNext):
e.searchNext()
case key.Matches(msg, d.SearchPrev):
e.searchPrev()
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
e.viewport.SetYOffset(e.viewport.YOffset() - 1) e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
@@ -35,16 +72,10 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
step = 1 step = 1
} }
e.viewport.SetYOffset(e.viewport.YOffset() + step) e.viewport.SetYOffset(e.viewport.YOffset() + step)
case key.Matches(msg, g.Help):
e.help.ShowAll = !e.help.ShowAll
e.SetSize(e.width, e.height)
} }
} }
return e, nil 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()
}
+26 -3
View File
@@ -2,7 +2,8 @@ package docs
import ( import (
"bytes" "bytes"
_ "embed" "fmt"
"strings"
"text/template" "text/template"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -10,6 +11,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config" "github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
) )
func windowStyle() lipgloss.Style { func windowStyle() lipgloss.Style {
@@ -20,7 +22,23 @@ func windowStyle() lipgloss.Style {
} }
func (e Model) View() tea.View { func (e Model) View() tea.View {
return tea.NewView(windowStyle().Render(e.viewport.View())) statusBar := e.renderStatusBar()
if len(e.matches) > 0 {
var countText string
if e.searching {
countText = fmt.Sprintf("%d matches", len(e.matches))
} else {
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
}
count := lipgloss.NewStyle().Padding(0, 1).
Foreground(style.S.MutedFg).
Render(countText)
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
}
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
windowStyle().Render(e.viewport.View()),
statusBar,
))
} }
func (m *Model) renderMarkdown() { func (m *Model) renderMarkdown() {
@@ -48,5 +66,10 @@ func (m *Model) renderMarkdown() {
) )
str, _ := renderer.Render(processed.String()) str, _ := renderer.Render(processed.String())
m.viewport.SetContent(str) m.renderedLines = strings.Split(str, "\n")
m.strippedLines = make([]string, len(m.renderedLines))
for i, l := range m.renderedLines {
m.strippedLines[i] = ansi.Strip(l)
}
m.applySearch()
} }
+9 -5
View File
@@ -81,7 +81,7 @@ func (m *Model) recalcSizes() {
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{})) return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{width: m.width}))
} }
// RefreshCmd loads findings from the database. // RefreshCmd loads findings from the database.
@@ -143,14 +143,18 @@ func renderMarkdown(src string, width int) string {
return out return out
} }
type findingsKeyMap struct{} type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding { func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
f := keys.Keys.Findings f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss} return []key.Binding{g.Up, g.Down, f.Dismiss, g.Help}
} }
func (findingsKeyMap) FullHelp() [][]key.Binding { func (m findingsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{findingsKeyMap{}.ShortHelp()} g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown}
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
} }
+8
View File
@@ -19,7 +19,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor >= len(m.findings) { if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1) m.cursor = max(0, len(m.findings)-1)
} }
if len(m.findings) == 0 {
m.pager.Page = 0
m.pager.TotalPages = 0
} else {
m.pager.SetTotalPages(len(m.findings)) m.pager.SetTotalPages(len(m.findings))
}
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
return m, nil return m, nil
@@ -76,6 +81,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
step = 1 step = 1
} }
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step) m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
} }
} }
return m, nil return m, nil
+4 -1
View File
@@ -28,7 +28,10 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
dots := s.Faint.Render(m.pager.View()) var dots string
if len(m.findings) > 0 {
dots = s.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
+13 -2
View File
@@ -55,6 +55,15 @@ func (m Model) IsEditing() bool {
return m.searchKind != searchKindOff && !m.searchAccepted return m.searchKind != searchKindOff && !m.searchAccepted
} }
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string { return "https" }
// RefreshCmd returns the appropriate load command given the current search state. // RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that // The app model should call this instead of LoadEntriesCmd directly so that
// background refreshes re-run the active search rather than resetting it. // background refreshes re-run the active search rather than resetting it.
@@ -136,14 +145,16 @@ func (historyKeyMap) ShortHelp() []key.Binding {
return []key.Binding{ return []key.Binding{
g.Up, g.Down, g.CycleFocus, g.Up, g.Down, g.CycleFocus,
h.DeleteEntry, h.DeleteAll, h.DeleteEntry, h.DeleteAll,
h.Filter, h.SqlQuery,
g.Help, g.Help,
} }
} }
func (m historyKeyMap) FullHelp() [][]key.Binding { func (m historyKeyMap) FullHelp() [][]key.Binding {
h := keys.Keys.History h := keys.Keys.History
g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery} all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
all = append(all, keys.Keys.Global.Bindings()...) all = append(all, pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+11
View File
@@ -6,6 +6,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -281,9 +282,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.entries) == 0 {
m.pager.Page = 0
m.pager.TotalPages = 0
} else {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
} }
}
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
@@ -299,5 +305,10 @@ func (m *Model) refreshBody() {
} else { } else {
raw = e.RequestRaw raw = e.RequestRaw
} }
if raw == "" {
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (˘・_・˘)\nno response stored")))
return
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
} }
+4 -1
View File
@@ -28,7 +28,10 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
dots := s.Faint.Render(m.pager.View()) var dots string
if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
+10
View File
@@ -295,17 +295,27 @@ func (m *Model) recalcSizes() {
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.queue) == 0 {
m.pager.Page = 0
m.pager.TotalPages = 0
} else {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.queue)) m.pager.SetTotalPages(len(m.queue))
} }
}
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
func (m *Model) refreshResponseListViewport() { func (m *Model) refreshResponseListViewport() {
if m.responsePager.PerPage > 0 { if m.responsePager.PerPage > 0 {
if len(m.responseQueue) == 0 {
m.responsePager.Page = 0
m.responsePager.TotalPages = 0
} else {
m.responsePager.Page = m.responseCursor / m.responsePager.PerPage m.responsePager.Page = m.responseCursor / m.responsePager.PerPage
m.responsePager.SetTotalPages(len(m.responseQueue)) m.responsePager.SetTotalPages(len(m.responseQueue))
} }
}
m.responseViewport.SetContent(m.renderResponseList()) m.responseViewport.SetContent(m.renderResponseList())
} }
+4 -1
View File
@@ -29,6 +29,9 @@ func (interceptKeyMap) ShortHelp() []key.Binding {
} }
func (m interceptKeyMap) FullHelp() [][]key.Binding { func (m interceptKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Intercept.Bindings(), keys.Keys.Global.Bindings()...) g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.SendToReplay, g.SendToDiff, g.Copy, g.CopyAs}
all := append(keys.Keys.Intercept.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
-1
View File
@@ -115,4 +115,3 @@ func (m *Model) SetSize(w, h int) {
m.height = h m.height = h
m.recalcSizes() m.recalcSizes()
} }
+11 -1
View File
@@ -5,14 +5,24 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case intercept.RequestArrivedMsg: case intercept.RequestArrivedMsg:
if !m.interceptEnabled { if !m.interceptEnabled {
+4 -1
View File
@@ -45,7 +45,10 @@ func (m *Model) renderListPanel(w, h int) string {
border = s.PanelFocused border = s.PanelFocused
} }
dots := s.Faint.Render(m.pager.View()) var dots string
if len(m.queue) > 0 {
dots = s.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
+67 -12
View File
@@ -1,7 +1,6 @@
package plugins package plugins
import ( import (
"os"
"strings" "strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
@@ -25,6 +24,7 @@ type Model struct {
filtered []plugins.Info filtered []plugins.Info
listViewport viewport.Model listViewport viewport.Model
detailViewport viewport.Model
textarea textarea.Model textarea textarea.Model
filterInput textinput.Model filterInput textinput.Model
filtering bool filtering bool
@@ -36,7 +36,7 @@ type Model struct {
} }
func New(mgr *plugins.Manager) Model { func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false) ta := style.NewTextarea(true)
ta.Placeholder = "plugin configuration..." ta.Placeholder = "plugin configuration..."
ta.Blur() ta.Blur()
@@ -46,6 +46,7 @@ func New(mgr *plugins.Manager) Model {
return Model{ return Model{
manager: mgr, manager: mgr,
listViewport: style.NewViewport(), listViewport: style.NewViewport(),
detailViewport: style.NewViewport(),
textarea: ta, textarea: ta,
filterInput: fi, filterInput: fi,
pager: style.NewPaginator(), pager: style.NewPaginator(),
@@ -88,10 +89,27 @@ func (m *Model) recalcSizes() {
} }
m.filterInput.SetWidth(inner - 2) m.filterInput.SetWidth(inner - 2)
detailContentH := style.PanelContentH(detailH)
const headerH = 2
const configFixedH = 2 // blank line + label line
textareaH := max(3, detailContentH/3)
if textareaH > 12 {
textareaH = 12
}
configTotalH := 0
if m.hasConfig() {
configTotalH = configFixedH + textareaH
}
descVH := max(1, detailContentH-headerH-configTotalH)
m.detailViewport.SetWidth(inner)
m.detailViewport.SetHeight(descVH)
m.textarea.SetWidth(max(1, inner-2)) m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6)) m.textarea.SetHeight(max(3, textareaH))
m.refreshListViewport() m.refreshListViewport()
m.syncDetailViewport()
} }
// Refresh reloads the plugin list from the manager. // Refresh reloads the plugin list from the manager.
@@ -126,6 +144,7 @@ func (m *Model) applyFilter() {
} }
m.refreshListViewport() m.refreshListViewport()
m.syncTextarea() m.syncTextarea()
m.syncDetailViewport()
} }
func (m *Model) selected() (plugins.Info, bool) { func (m *Model) selected() (plugins.Info, bool) {
@@ -135,6 +154,15 @@ func (m *Model) selected() (plugins.Info, bool) {
return m.filtered[m.cursor], true return m.filtered[m.cursor], true
} }
func (m *Model) hasConfig() bool {
info, ok := m.selected()
if !ok {
return false
}
_, has := info.Hooks["on_config"]
return has
}
func (m *Model) syncTextarea() { func (m *Model) syncTextarea() {
if m.editing { if m.editing {
return return
@@ -147,24 +175,35 @@ func (m *Model) syncTextarea() {
m.textarea.SetValue(info.ConfigText) m.textarea.SetValue(info.ConfigText)
} }
func (m *Model) syncDetailViewport() {
info, ok := m.selected()
if !ok || info.Description == "" {
m.detailViewport.SetContent("")
return
}
desc := renderPluginDescription(info.Description, m.width-6)
m.detailViewport.SetContent(desc)
}
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.filtered) == 0 {
m.pager.Page = 0
m.pager.TotalPages = 0
} else {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.filtered)) m.pager.SetTotalPages(len(m.filtered))
} }
}
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
func shortenPath(p string) string { type pluginsKeyMap struct {
home := os.Getenv("HOME") editing bool
if home != "" && strings.HasPrefix(p, home) { hasConfig bool
return "~" + p[len(home):] width int
}
return p
} }
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding { func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
g := keys.Keys.Global g := keys.Keys.Global
@@ -172,9 +211,25 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit")) esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc} return []key.Binding{esc}
} }
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter} scrollHint := key.NewBinding(
key.WithKeys(g.ScrollUp.Keys()...),
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
)
if k.hasConfig {
return []key.Binding{pk.Toggle, pk.EditConfig, scrollHint, g.Help}
}
return []key.Binding{pk.Toggle, scrollHint, g.Help}
} }
func (k pluginsKeyMap) FullHelp() [][]key.Binding { func (k pluginsKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
if k.editing {
return [][]key.Binding{k.ShortHelp()} return [][]key.Binding{k.ShortHelp()}
}
pk := keys.Keys.Plugins
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Escape}
all := []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
all = append(all, pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, k.width)
} }
+39 -3
View File
@@ -21,7 +21,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
}
case tea.KeyPressMsg: case tea.KeyPressMsg:
pk := keys.Keys.Plugins pk := keys.Keys.Plugins
g := keys.Keys.Global g := keys.Keys.Global
@@ -90,15 +110,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, g.Down): case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 { if m.cursor < len(m.filtered)-1 {
m.cursor++ m.cursor++
m.refreshListViewport() m.recalcSizes()
m.syncTextarea() m.syncTextarea()
m.detailViewport.GotoTop()
} }
case key.Matches(msg, pk.Toggle): case key.Matches(msg, pk.Toggle):
@@ -115,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case key.Matches(msg, pk.EditConfig): case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok { if _, ok := m.selected(); ok && m.hasConfig() {
m.editing = true m.editing = true
m.textarea.Focus() m.textarea.Focus()
} }
case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
+58 -16
View File
@@ -1,10 +1,13 @@
package plugins package plugins
import ( import (
"path/filepath"
"strings" "strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -12,7 +15,7 @@ import (
func (m Model) View() tea.View { func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil { if m.width == 0 || m.manager == nil {
return tea.NewView(style.S.Faint.Render("\nno plugins loaded")) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (._.)~*.'\n no plugins loaded")))
} }
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4) listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
@@ -27,23 +30,32 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
dots := s.Faint.Render(m.pager.View()) panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
var dots string
if len(m.filtered) > 0 {
dots = s.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
} }
func (m *Model) renderDetailPanel(h int) string { func (m *Model) renderDetailPanel(h int) string {
s := style.S s := style.S
panelStyle := s.Panel
if m.editing {
panelStyle = s.PanelFocused
}
info, ok := m.selected() info, ok := m.selected()
if !ok { if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h) return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
} }
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error) statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled { if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success) statusSt = lipgloss.NewStyle().Foreground(s.Success)
@@ -52,22 +64,52 @@ func (m *Model) renderDetailPanel(h int) string {
if info.Enabled { if info.Enabled {
status = "enabled" status = "enabled"
} }
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
pad := lipgloss.NewStyle().Padding(0, 1)
header := pad.Render(
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
s.Faint.Render(filepath.Base(info.FilePath)),
)
parts := []string{header, m.detailViewport.View()}
if m.hasConfig() {
var configLabel string
if m.editing { if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):")) configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
} else { } else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key editKey := keys.Keys.Plugins.EditConfig.Help().Key
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):")) configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
}
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
} }
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()), return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()), }
func renderPluginDescription(desc string, width int) string {
desc = strings.TrimSpace(desc)
lines := strings.Split(desc, "\n")
for i, l := range lines {
lines[i] = strings.TrimLeft(l, " \t")
}
desc = strings.Join(lines, "\n")
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
) )
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h) if err != nil {
return desc
}
out, err := r.Render(desc)
if err != nil {
return desc
}
return strings.Trim(out, "\n")
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -81,9 +123,9 @@ func (m *Model) renderStatusBar() string {
escKey := keys.Keys.Global.Escape.Help().Key escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary) accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear")) 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 lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width})))
} }
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})) return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width}))
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
+21 -1
View File
@@ -68,6 +68,23 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing }
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
}
if s := m.entries[m.cursor].Scheme; s != "" {
return s
}
return "https"
}
func (m *Model) SetDB(d *db.DB) { func (m *Model) SetDB(d *db.DB) {
m.database = d m.database = d
if d == nil { if d == nil {
@@ -170,6 +187,9 @@ func (replayKeyMap) ShortHelp() []key.Binding {
} }
func (m replayKeyMap) FullHelp() [][]key.Binding { func (m replayKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Replay.Bindings(), keys.Keys.Global.Bindings()...) g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs}
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width) return keys.ChunkByWidth(all, m.width)
} }
+21 -3
View File
@@ -12,6 +12,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/db" "github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -33,6 +34,18 @@ func sendCmd(entry Entry, index int) tea.Cmd {
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) { switch msg := msg.(type) {
case SendToReplayMsg: case SendToReplayMsg:
entry := entryFromMsg(msg) entry := entryFromMsg(msg)
@@ -104,7 +117,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateNormalMode(msg) return m.updateNormalMode(msg)
} }
return m, nil return m, tea.Batch(cmds...)
} }
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -229,9 +242,14 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
func (m *Model) refreshListViewport() { func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 { if m.pager.PerPage > 0 {
if len(m.entries) == 0 {
m.pager.Page = 0
m.pager.TotalPages = 0
} else {
m.pager.Page = m.cursor / m.pager.PerPage m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
} }
}
m.listViewport.SetContent(m.renderList()) m.listViewport.SetContent(m.renderList())
} }
@@ -247,11 +265,11 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0) m.requestViewport.SetXOffset(0)
if e.Sending { if e.Sending {
m.responseViewport.SetContent(style.HighlightHTTP("Sending...")) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (ノ◕ヮ◕)ノ*:・゚\n sending...")))
} else if e.ResponseRaw != "" { } else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw)) m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else { } else {
m.responseViewport.SetContent("") m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" ( •_•)>⌐■\npress send to fire")))
} }
m.responseViewport.SetYOffset(0) m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0) m.responseViewport.SetXOffset(0)
+9 -2
View File
@@ -33,12 +33,19 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string { func (m *Model) renderListPanel(w, h int) string {
s := style.S s := style.S
dots := s.Faint.Render(m.pager.View()) panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
var dots string
if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View())
}
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots), lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
) )
return style.RenderWithTitle(s.PanelFocused, icons.I.Replay+"Replay", inner, w, h) return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
} }
func (m *Model) renderRequestPanel(w, h int) string { func (m *Model) renderRequestPanel(w, h int) string {
+19
View File
@@ -0,0 +1,19 @@
pre-commit:
piped: true
commands:
check-vendor-hash:
glob: "{go.mod,go.sum}"
run: .github/scripts/check-vendor-hash.sh
stage_fixed: true
inject-exec-basics:
glob: "{docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py docs/basics.md
stage_fixed: true
inject-exec:
glob: "{README.md,docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py README.md
stage_fixed: true
toc:
glob: "{README.md,docs/basics.md,cmd/**}"
run: doctoc --notitle README.md
stage_fixed: true
+45
View File
@@ -0,0 +1,45 @@
package spilltea
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
)
//go:embed plugins/*.lua
var PluginsFS embed.FS
// InstallDefaultPlugins copies embedded default plugins into dir, skipping
// files that already exist. Returns the number of files written.
func InstallDefaultPlugins(dir string) (int, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, fmt.Errorf("create plugins dir: %w", err)
}
entries, err := fs.ReadDir(PluginsFS, "plugins")
if err != nil {
return 0, err
}
written := 0
for _, e := range entries {
if e.IsDir() {
continue
}
dst := filepath.Join(dir, e.Name())
if _, err := os.Stat(dst); err == nil {
continue
}
data, err := PluginsFS.ReadFile("plugins/" + e.Name())
if err != nil {
return written, fmt.Errorf("read embedded %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return written, fmt.Errorf("write %s: %w", dst, err)
}
written++
}
return written, nil
}
@@ -1,26 +1,28 @@
-- Inject a custom header into every request.
-- Config format (one per line): Header-Name: value
Plugin = { Plugin = {
name = "Inject Header", name = "Inject Header",
description = [[
Inject custom headers into every intercepted request.
**Config**:
- one 'Header-Name: value' per line.
]],
on_request = { sync = true }, on_request = { sync = true },
} }
local headers = {} local headers = {}
function on_start(config_text) function on_config(config_text)
headers = {}
for line in config_text:gmatch("[^\n]+") do for line in config_text:gmatch("[^\n]+") do
local name, value = line:match("^([^:]+):%s*(.+)$") local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then if name and value then
table.insert(headers, { name = name, value = value }) table.insert(headers, { name = name, value = value })
end end
end end
log("loaded " .. #headers .. " header(s)")
end end
function on_request(req) function on_request(req)
for _, h in ipairs(headers) do for _, h in ipairs(headers) do
req:set_header(h.name, h.value) req:set_header(h.name, h.value)
end end
return "forward"
end end
+75
View File
@@ -0,0 +1,75 @@
Plugin = {
name = "IP Filter (Whitelist/Blacklist)",
description = [[
Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**:
- one IP per line
- prefix with `!` for a blacklist entry (blocked)
- prefix with `#` to comment it out (ignored)
- if no IPs are configured, the check is skipped
]],
on_start = { sync = false },
}
local whitelist = {}
local blacklist = {}
function on_config(config_text)
whitelist = {}
blacklist = {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(blacklist, ip)
end
else
table.insert(whitelist, trimmed)
end
end
end
end
function on_start()
if #whitelist == 0 and #blacklist == 0 then
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")
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
return
end
for _, ip in ipairs(blacklist) do
if result == ip then
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
return
end
end
if #whitelist == 0 then
return
end
for _, ip in ipairs(whitelist) do
if result == ip then
return
end
end
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
end
+104
View File
@@ -0,0 +1,104 @@
Plugin = {
name = "Scopes",
description = [[
Auto-forward requests and exclude them from history based on patterns.
**Config**:
- `pattern` - whitelist: only intercept matching requests/responses and history entries
- `!pattern` - blacklist: skip matching requests/responses and history entries
- `r:pattern` - whitelist for requests/responses only (history unaffected)
- `r:!pattern` - blacklist for requests/responses only
- `h:pattern` - whitelist for history entries only (requests unaffected)
- `h:!pattern` - blacklist for history entries only
- lines starting with `#` are comments
Example (ignore static assets):
```
!%.css$
!%.js$
!%.png$
```
Example (focus on mytarget.com, skip everything else):
```
mytarget%.com/
```
Example (intercept mytarget.com except its static assets):
```
mytarget%.com/
!%.css$
!%.js$
!%.png$
```
Example (disable history — h: whitelist never matches any real URL):
```
h:^$
```
]],
priority = 100,
on_request = { sync = true },
on_response = { sync = true },
on_history_entry = { sync = true },
}
local blacklist = {}
local whitelist = {}
local blacklist_req = {}
local whitelist_req = {}
local blacklist_hist = {}
local whitelist_hist = {}
function on_config(config_text)
blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {}
for line in config_text:gmatch("[^\n]+") do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):")
local rest = scope and trimmed:sub(3) or trimmed
local is_bl = rest:sub(1, 1) == "!"
local pattern = is_bl and rest:sub(2) or rest
if scope == "r" then
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
elseif scope == "h" then
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
else
table.insert(is_bl and blacklist or whitelist, pattern)
end
end
end
end
local function check_skip(url, bl_extra, wl_extra)
for _, p in ipairs(blacklist) do
if url:match(p) then return true end
end
for _, p in ipairs(bl_extra) do
if url:match(p) then return true end
end
local wl = {}
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
if #wl > 0 then
for _, p in ipairs(wl) do
if url:match(p) then return false end
end
return true
end
return false
end
function on_request(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
end
function on_response(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
end
function on_history_entry(entry)
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
end