mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
117 Commits
v0.0.3
...
a75b65644f
| Author | SHA1 | Date | |
|---|---|---|---|
| a75b65644f | |||
| cc0176a73d | |||
| ec0fb61099 | |||
| ee3e6806c7 | |||
| 36eab35576 | |||
| 8211d87379 | |||
| ef1789af2f | |||
| 31bb2aa439 | |||
| b3caa9852c | |||
| 39248e2be6 | |||
| 9d0ef598a7 | |||
| 77b102791f | |||
| 2e0ad98a4a | |||
| b0afb7cb56 | |||
| acee3df1bc | |||
| 61a112a302 | |||
| 677223c9b0 | |||
| 098400ba77 | |||
| 4922f3704d | |||
| 1aa4b92bb3 | |||
| 437e8f883d | |||
| 5c74eda48b | |||
| 1190276bab | |||
| 6128dcc15e | |||
| 41d5366952 | |||
| 8683d17242 | |||
| 2e61ffa125 | |||
| 9ac6040629 | |||
| c7523e69e7 | |||
| aa458c142f | |||
| 0b395e018a | |||
| 5841ed0a95 | |||
| 9cabe81771 | |||
| 021090f52c | |||
| 0b9e1a1cf0 | |||
| f60cdf2033 | |||
| 6af1388652 | |||
| a708830309 | |||
| 44b3c67a37 | |||
| 6e673f5c11 | |||
| 2c63cdbeff | |||
| 0e982c6ade | |||
| 04ba32cbd5 | |||
| f7e9da94cc | |||
| 9253d85c81 | |||
| 1bb547870e | |||
| 4251e4fb2a | |||
| b547a79d6e | |||
| fe58468abf | |||
| 3542098905 | |||
| f78b3f7174 | |||
| 722021ba02 | |||
| e18f660e83 | |||
| 67fe8eb911 | |||
| af872afbe8 | |||
| 2225afd9ee | |||
| 6dc959de77 | |||
| 0017f37c33 | |||
| 924cb73afb | |||
| 746f1afd1b | |||
| 905013943d | |||
| c6bca887cb | |||
| dcf9cb4c8e | |||
| ae372d7283 | |||
| e20250f0a0 | |||
| 3463e51739 | |||
| 87fa9448d6 | |||
| 4240c4ceb9 | |||
| d79c9f91d1 | |||
| 33e2afe709 | |||
| 2c3e19258f | |||
| 69d5d0ffec | |||
| d47f51d2b5 | |||
| 598455f8d3 | |||
| 28b070dafc | |||
| 6f56e0b26a | |||
| eaa960e6ab | |||
| f874a70639 | |||
| 4643989ab6 | |||
| 7bbc00880a | |||
| 385b6e84e0 | |||
| 6a9935ec27 | |||
| b490c7a0ac | |||
| 1a1c0cff30 | |||
| 172a77e13b | |||
| 41c0e489cf | |||
| 79128bb865 | |||
| 48de2a8e10 | |||
| b4a45a23e5 | |||
| b5e2721aa1 | |||
| 0cfba17d3d | |||
| a147e8b972 | |||
| 03260e0947 | |||
| ed59923b7d | |||
| aa7b639f82 | |||
| 27e0c418e9 | |||
| 08757a5d1d | |||
| ffee0978e6 | |||
| 4f45a7c061 | |||
| 0fafa52c65 | |||
| 366bb682d2 | |||
| 9fe0c74150 | |||
| 3b6b58ac2b | |||
| 789a513469 | |||
| 615093bd8b | |||
| 7e1b7d3b5a | |||
| e3e89582c1 | |||
| 2705c2882d | |||
| 6ea692754a | |||
| 85c2806604 | |||
| d451965fa0 | |||
| d82a220e91 | |||
| 1ac5eb26e8 | |||
| 969febb14c | |||
| 6aa377acd8 | |||
| 2f4765bf37 | |||
| fac335a16e |
Binary file not shown.
|
After Width: | Height: | Size: 906 KiB |
@@ -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
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
## Configuring your browser's proxy settings
|
|
||||||
|
|
||||||
We recommend installing **FoxyProxy** to manage your browser's proxies.
|
|
||||||
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
|
||||||
|
|
||||||
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
|
||||||
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
|
|
||||||
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
|
|
||||||
4. You're all set! You can now use Spilltea.
|
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -23,6 +23,6 @@ jobs:
|
|||||||
- uses: goreleaser/goreleaser-action@v6
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean
|
args: release --clean --config .github/.goreleaser.yaml
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
result/
|
result/
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|||||||
@@ -9,10 +9,28 @@
|
|||||||
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
> Think Burp Suite or Caido, but entirely in your terminal.
|
> Think Burp Suite or Caido, but entirely in your terminal.
|
||||||
|
|
||||||
[](go.mod)
|

|
||||||
[](https://github.com/anotherhadi/spilltea/releases)
|

|
||||||
[](LICENSE)
|

|
||||||
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
[](https://ko-fi.com/anotherhadi)
|
||||||
|
|
||||||
|
<!-- 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)
|
||||||
|
- [Per-project configuration](#per-project-configuration)
|
||||||
|
- [CLI Flags](#cli-flags)
|
||||||
|
- [Plugin System](#plugin-system)
|
||||||
|
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||||
|
- [Deployment](#deployment)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
|
||||||
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
||||||
## What is Spilltea?
|
## What is Spilltea?
|
||||||
|
|
||||||
@@ -20,49 +38,135 @@ Spilltea is a **terminal-native HTTP(S) interception proxy**. It sits between yo
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
On startup, you choose:
|
On startup, you choose:
|
||||||
|
|
||||||
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default
|
- **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
|
||||||
- **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/<random-id>/` 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) or [plugin examples](./plugins/).
|
|
||||||
|
|
||||||
## 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`.
|
||||||
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||||
|
|
||||||
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
## CLI Flags
|
## CLI Flags
|
||||||
|
|
||||||
| Flag | Short | Description |
|
```
|
||||||
| ----------------------- | ----- | ------------------------------------------------------------------------------ |
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
|
|
||||||
| `--plugin-dir` | | Path to plugins dir, overrides config (default: `~/.config/spilltea/plugins/`) |
|
Usage:
|
||||||
| `--host` | | Proxy host, overrides config |
|
spilltea [flags]
|
||||||
| `--port` | `-p` | Proxy port, overrides config |
|
|
||||||
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
|
Flags:
|
||||||
| `--upstream-proxy` | | Upstream proxy URL, overrides config (e.g. `http://user:pass@host:8888`) |
|
--add-default-config copy the default config file to the config path and exit
|
||||||
| `--version` | `-v` | Print version and exit |
|
--add-default-plugins copy built-in example plugins into the plugins dir and exit
|
||||||
| `--add-default-plugins` | | Add the default plugins to your plugins dir and exit |
|
-c, --config string path to config file
|
||||||
|
-h, --help help for spilltea
|
||||||
|
--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
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
|
-v, --version version for spilltea
|
||||||
|
```
|
||||||
|
<!-- 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/).
|
||||||
|
|
||||||
|
## Vim / Neovim Integration
|
||||||
|
|
||||||
|
Spilltea pairs naturally with Neovim. If you live in your editor, these plugins let you inspect and transform captured traffic without switching context:
|
||||||
|
|
||||||
|
- **[sttr.nvim](https://github.com/anotherhadi/sttr.nvim)**: Run string transformations (base64, URL encode/decode, hex, hashing, and more) on any selected text directly from Neovim. Handy for decoding values spotted in the proxy history.
|
||||||
|
- **[jwt-tui.nvim](https://github.com/anotherhadi/jwt-tui.nvim)**: Decode and inspect JWT tokens in a floating TUI window. Great for quickly reading tokens intercepted through the proxy.
|
||||||
|
|
||||||
|
Both plugins are built to feel native in a terminal workflow, just like Spilltea.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
+119
-75
@@ -2,9 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
spilltea "github.com/anotherhadi/spilltea"
|
spilltea "github.com/anotherhadi/spilltea"
|
||||||
@@ -15,123 +15,167 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
flag "github.com/spf13/pflag"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set.
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if version != "dev" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
flagConfig = flag.StringP("config", "c", "", "path to config file")
|
flagConfig string
|
||||||
flagPluginsDir = flag.String("plugins-dir", "", "path to plugins dir (overrides config)")
|
flagPluginsDir string
|
||||||
flagHost = flag.String("host", "", "proxy host (overrides config)")
|
flagHost string
|
||||||
flagPort = flag.IntP("port", "p", 0, "proxy port (overrides config)")
|
flagPort int
|
||||||
flagUpstreamProxy = flag.String("upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
flagUpstreamProxy string
|
||||||
flagVersion = flag.BoolP("version", "v", false, "print version")
|
flagProject string
|
||||||
flagProject = flag.StringP("project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
flagSslInsecure bool
|
||||||
flagAddDefaultPlugins = flag.Bool("add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
flagAddDefaultPlugins bool
|
||||||
|
flagAddDefaultConfig bool
|
||||||
)
|
)
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *flagVersion {
|
rootCmd := &cobra.Command{
|
||||||
fmt.Println(version)
|
Use: "spilltea",
|
||||||
os.Exit(0)
|
Short: "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.",
|
||||||
}
|
Version: version,
|
||||||
|
SilenceUsage: true,
|
||||||
if *flagAddDefaultPlugins {
|
SilenceErrors: true,
|
||||||
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if *flagConfig != "" {
|
if flagAddDefaultPlugins {
|
||||||
cfgPath = *flagConfig
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
}
|
}
|
||||||
if err := config.Load(cfgPath); err != nil {
|
if err := config.Load(cfgPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
dir := config.ExpandPath(config.Global.App.PluginsDir)
|
||||||
if *flagPluginsDir != "" {
|
if flagPluginsDir != "" {
|
||||||
dir = *flagPluginsDir
|
dir = flagPluginsDir
|
||||||
}
|
}
|
||||||
n, err := spilltea.InstallDefaultPlugins(dir)
|
n, err := spilltea.InstallDefaultPlugins(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "add-default-plugins: %v\n", err)
|
return fmt.Errorf("add-default-plugins: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
fmt.Printf("added %d plugin(s) to %s\n", n, dir)
|
||||||
os.Exit(0)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flagProject != "" && !homeUI.IsValidProjectName(*flagProject) {
|
if flagAddDefaultConfig {
|
||||||
fmt.Fprintf(os.Stderr, "project: invalid name %q (only lowercase letters, digits, - and _ are allowed)\n", *flagProject)
|
home, _ := os.UserHomeDir()
|
||||||
os.Exit(1)
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
|
}
|
||||||
|
if err := config.WriteDefaultConfig(cfgPath); err != nil {
|
||||||
|
return fmt.Errorf("add-default-config: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("default config written to %s\n", cfgPath)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgPath := filepath.Join(os.Getenv("HOME"), ".config", "spilltea", "config.yaml")
|
if flagProject != "" && !homeUI.IsValidProjectName(flagProject) {
|
||||||
if *flagConfig != "" {
|
return fmt.Errorf("project: invalid name %q (only lowercase letters, digits, - and _ are allowed)", flagProject)
|
||||||
cfgPath = *flagConfig
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
cfgPath := filepath.Join(home, ".config", "spilltea", "config.yaml")
|
||||||
|
if flagConfig != "" {
|
||||||
|
cfgPath = flagConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := config.Load(cfgPath); err != nil {
|
if err := config.Load(cfgPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config: %v\n", err)
|
return fmt.Errorf("config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
config.Global.Version = version
|
config.Global.Version = version
|
||||||
|
|
||||||
if *flagPluginsDir != "" {
|
sslInsecureChanged := cmd.Flags().Changed("ssl-insecure")
|
||||||
config.Global.App.PluginsDir = *flagPluginsDir
|
applyCLI := func(c *config.Config) {
|
||||||
|
if flagPluginsDir != "" {
|
||||||
|
c.App.PluginsDir = flagPluginsDir
|
||||||
}
|
}
|
||||||
if *flagHost != "" {
|
if flagHost != "" {
|
||||||
config.Global.App.Host = *flagHost
|
c.App.Host = flagHost
|
||||||
}
|
}
|
||||||
if *flagPort != 0 {
|
if flagPort != 0 {
|
||||||
config.Global.App.Port = *flagPort
|
c.App.Port = flagPort
|
||||||
}
|
}
|
||||||
if *flagUpstreamProxy != "" {
|
if flagUpstreamProxy != "" {
|
||||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
c.App.UpstreamProxy = flagUpstreamProxy
|
||||||
}
|
}
|
||||||
|
if sslInsecureChanged {
|
||||||
|
c.App.SslInsecure = flagSslInsecure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyCLI(config.Global)
|
||||||
|
config.SetCLIOverrides(applyCLI)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", config.Global.App.Host, config.Global.App.Port)
|
style.Init()
|
||||||
// Check if the proxy port is available before starting the UI.
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "proxy: cannot bind to %s: %v\n", addr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
ln.Close()
|
|
||||||
|
|
||||||
style.Init(config.Global)
|
|
||||||
icons.Init(config.Global)
|
icons.Init(config.Global)
|
||||||
keys.Init(config.Global)
|
keys.Init(config.Global)
|
||||||
|
|
||||||
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
projectDir := config.ExpandPath(config.Global.App.ProjectDir)
|
||||||
|
|
||||||
// Resolve project: either from --project flag or by running the home UI.
|
if flagProject != "" {
|
||||||
var project *homeUI.Project
|
project, err := homeUI.OpenProject(projectDir, flagProject)
|
||||||
if *flagProject != "" {
|
|
||||||
p, err := homeUI.OpenProject(projectDir, *flagProject)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "project: %v\n", err)
|
return fmt.Errorf("project: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
project = p
|
if err := config.MergeProjectConfig(filepath.Dir(project.Path)); err != nil {
|
||||||
} else {
|
return fmt.Errorf("project config: %w", err)
|
||||||
finalModel, err := tea.NewProgram(homeUI.New(projectDir)).Run()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
project = finalModel.(homeUI.Model).Selected()
|
icons.Init(config.Global)
|
||||||
}
|
keys.Init(config.Global)
|
||||||
|
|
||||||
// User quit the home screen without selecting a project.
|
|
||||||
if project == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
broker := intercept.NewBroker()
|
broker := intercept.NewBroker()
|
||||||
m := appUI.New(broker, project.Name, project.Path)
|
m := appUI.New(broker, project.Name, project.Path)
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
final, err := tea.NewProgram(m).Run()
|
||||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if app, ok := final.(appUI.Model); ok {
|
||||||
|
if ferr := app.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root := rootModel{home: homeUI.New(projectDir)}
|
||||||
|
final, err := tea.NewProgram(root).Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tui: %w", err)
|
||||||
|
}
|
||||||
|
if r, ok := final.(rootModel); ok {
|
||||||
|
if ferr := r.FatalErr(); ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
|
||||||
|
rootCmd.Flags().StringVar(&flagPluginsDir, "plugins-dir", "", "path to plugins dir (overrides config)")
|
||||||
|
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
|
||||||
|
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
|
||||||
|
rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
|
||||||
|
rootCmd.Flags().BoolVar(&flagSslInsecure, "ssl-insecure", false, "skip TLS certificate verification (overrides config)")
|
||||||
|
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
|
||||||
|
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
appUI "github.com/anotherhadi/spilltea/internal/ui/app"
|
||||||
|
homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rootState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
rootStateHome rootState = iota
|
||||||
|
rootStateApp
|
||||||
|
)
|
||||||
|
|
||||||
|
type rootModel struct {
|
||||||
|
state rootState
|
||||||
|
home homeUI.Model
|
||||||
|
app tea.Model
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) Init() tea.Cmd {
|
||||||
|
return m.home.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.width = ws.Width
|
||||||
|
m.height = ws.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.state == rootStateHome {
|
||||||
|
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
|
||||||
|
if err := config.MergeProjectConfig(filepath.Dir(sel.Project.Path)); err != nil {
|
||||||
|
log.Printf("project config: %v", err)
|
||||||
|
}
|
||||||
|
icons.Init(config.Global)
|
||||||
|
keys.Init(config.Global)
|
||||||
|
broker := intercept.NewBroker()
|
||||||
|
app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
|
||||||
|
m.app = app
|
||||||
|
m.state = rootStateApp
|
||||||
|
return m, tea.Batch(app.Init(), func() tea.Msg {
|
||||||
|
return tea.WindowSizeMsg{Width: m.width, Height: m.height}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
updated, cmd := m.home.Update(msg)
|
||||||
|
m.home = updated.(homeUI.Model)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd := m.app.Update(msg)
|
||||||
|
m.app = updated
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) View() tea.View {
|
||||||
|
if m.state == rootStateApp {
|
||||||
|
return m.app.(interface{ View() tea.View }).View()
|
||||||
|
}
|
||||||
|
return m.home.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m rootModel) FatalErr() error {
|
||||||
|
if m.state == rootStateApp {
|
||||||
|
if app, ok := m.app.(appUI.Model); ok {
|
||||||
|
return app.FatalErr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package spilltea
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed .github/docs
|
|
||||||
var DocsFS embed.FS
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## 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/<name>/` by default
|
||||||
|
- **Existing project**: pick from a list of previous projects
|
||||||
|
- **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` 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)
|
||||||
|
|
||||||
|
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||||
|
|
||||||
|
### Per-project configuration
|
||||||
|
|
||||||
|
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
|
||||||
|
|
||||||
|
Only the keys present in that file are overridden; everything else falls back to the global config.
|
||||||
|
|
||||||
|
The priority order is:
|
||||||
|
|
||||||
|
1. Global config (`~/.config/spilltea/config.yaml`)
|
||||||
|
2. Project config (`<project-dir>/config.yaml`)
|
||||||
|
3. CLI flags (always win)
|
||||||
|
|
||||||
|
## CLI Flags
|
||||||
|
|
||||||
|
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||||
|
```
|
||||||
|
A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
spilltea [flags]
|
||||||
|
|
||||||
|
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
|
||||||
|
-h, --help help for spilltea
|
||||||
|
--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
|
||||||
|
--ssl-insecure skip TLS certificate verification (overrides config)
|
||||||
|
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||||
|
-v, --version version for spilltea
|
||||||
|
```
|
||||||
|
<!-- endexec -->
|
||||||
@@ -5,10 +5,13 @@
|
|||||||
- On Chrome:
|
- On Chrome:
|
||||||
- Open your Chrome settings, search for "Certificates" and click on "Security".
|
- Open your Chrome settings, search for "Certificates" and click on "Security".
|
||||||
- In the security settings page, scroll down and click on "Manage certificates".
|
- In the security settings page, scroll down and click on "Manage certificates".
|
||||||
- Select the "Authorities" tab and click on "Import tab and click on "Import".
|
- Select the "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}}`.
|
||||||
- On Firefox:
|
- On Firefox:
|
||||||
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
|
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
|
||||||
- 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.
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## How do I convert a temporary session into a regular project?
|
||||||
|
|
||||||
|
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
|
||||||
|
|
||||||
|
**1. Find the temporary session directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /tmp/spilltea/
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
|
||||||
|
|
||||||
|
**2. Move it to the projects folder**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The project name must only contain lowercase letters, digits, `-`, and `_`.
|
||||||
|
|
||||||
|
**3. Open the project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spilltea -P my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Or simply launch Spilltea and pick `my-project` from the project list.
|
||||||
@@ -4,9 +4,7 @@ The History page has a built-in search bar with two modes:
|
|||||||
|
|
||||||
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
|
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
|
||||||
|
|
||||||
**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table.
|
**SQL mode**: press `:` to open it, then `Enter` to run. Type a WHERE expression: the full `SELECT … FROM entries WHERE` is added automatically.
|
||||||
|
|
||||||
WHERE expression (the `SELECT` is added automatically):
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
status_code = 404
|
status_code = 404
|
||||||
@@ -16,10 +14,8 @@ status_code = 404
|
|||||||
host LIKE '%.api.%' AND method = 'POST'
|
host LIKE '%.api.%' AND method = 'POST'
|
||||||
```
|
```
|
||||||
|
|
||||||
Full SELECT query:
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
|
response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
|
||||||
```
|
```
|
||||||
|
|
||||||
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
|
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
|
||||||
@@ -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.
|
||||||
@@ -11,5 +11,7 @@
|
|||||||
# Spilltea Documentation
|
# Spilltea Documentation
|
||||||
|
|
||||||
- **Version**: `{{.Cfg.Version}}`
|
- **Version**: `{{.Cfg.Version}}`
|
||||||
|
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
|
||||||
|
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
|
||||||
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
- **Repository**: `https://github.com/anotherhadi/spilltea`
|
||||||
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Plugins
|
# Plugins
|
||||||
|
|
||||||
|
> **Warning:** Plugins can execute arbitrary shell commands, read and write files via `shell_pipe`, and access all intercepted traffic. Only load plugins you trust and have reviewed. You are solely responsible for the plugins you run.
|
||||||
|
|
||||||
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
|
||||||
You can found some pre-built plugins [here](../../plugins/).
|
You can found some pre-built plugins [here](../../plugins/).
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ Plugin = {
|
|||||||
name = "My Plugin",
|
name = "My Plugin",
|
||||||
description = "What this plugin does.",
|
description = "What this plugin does.",
|
||||||
priority = 0, -- higher = runs before other plugins (default: 0)
|
priority = 0, -- higher = runs before other plugins (default: 0)
|
||||||
|
disable_by_default = true, -- if true, plugin starts disabled on first load (default: false)
|
||||||
|
|
||||||
-- Declare which hooks you use and whether they are synchronous (default: false).
|
-- 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_config and on_quit are always sync and do not need to be declared here.
|
||||||
@@ -30,14 +33,14 @@ Plugin = {
|
|||||||
|
|
||||||
### Hook reference
|
### Hook reference
|
||||||
|
|
||||||
| Hook | When called | Sync/async | Return value (sync only) |
|
| Hook | When called | Sync/async | Return value |
|
||||||
| ------------------------- | ------------------------------------ | ------------- | ----------------------------------------------------- |
|
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| `on_config(config_text)` | At startup and on config save | always sync | ignored |
|
| `on_config()` | At startup and on config save | always sync | ignored |
|
||||||
| `on_start()` | Once at startup, after `on_config` | configurable | ignored |
|
| `on_start()` | Once at startup, after `on_config` | configurable | `false` to self-disable the plugin, otherwise ignored |
|
||||||
| `on_quit()` | When the app exits | always sync | ignored |
|
| `on_quit()` | When the app exits | always sync | ignored |
|
||||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` |
|
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
|
||||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` |
|
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (nil does nothing and le the user/TUI choose) (sync only) |
|
||||||
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) |
|
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) (sync only) |
|
||||||
|
|
||||||
## Request and response objects
|
## Request and response objects
|
||||||
|
|
||||||
@@ -109,17 +112,87 @@ end
|
|||||||
|
|
||||||
-- Quit the app (useful for startup checks that fail)
|
-- Quit the app (useful for startup checks that fail)
|
||||||
quit("reason message")
|
quit("reason message")
|
||||||
|
|
||||||
|
-- Run a shell command, optionally piping a string to its stdin.
|
||||||
|
-- Returns: output string, error string (nil on success).
|
||||||
|
-- The command runs via "sh -c" with a 30-second timeout.
|
||||||
|
local out, err = shell_pipe("trufflehog filesystem --no-update --json /dev/stdin", body)
|
||||||
|
if err then
|
||||||
|
log("command failed: " .. err)
|
||||||
|
else
|
||||||
|
log("output: " .. out)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Send an HTTP request directly (bypasses the proxy pipeline and other plugins).
|
||||||
|
-- method: HTTP method ("GET", "POST", etc.)
|
||||||
|
-- url: full URL to request
|
||||||
|
-- headers: table of request headers (optional, pass {} if unused)
|
||||||
|
-- body: request body string (optional, pass "" if unused)
|
||||||
|
-- options: optional table of options:
|
||||||
|
-- insecure = true skip TLS certificate verification
|
||||||
|
-- Returns: response table, error string (nil on success).
|
||||||
|
local res, err = send_request("POST", "https://example.com/api", {
|
||||||
|
["Authorization"] = "Bearer " .. token,
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
}, '{"key":"value"}', { insecure = true })
|
||||||
|
if err then
|
||||||
|
log("request failed: " .. err)
|
||||||
|
else
|
||||||
|
log(tostring(res.status_code))
|
||||||
|
log(res.body)
|
||||||
|
log(res.headers["Content-Type"] or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the plugin's config section as a Lua table (parsed from YAML).
|
||||||
|
-- Returns an empty table if no config is set.
|
||||||
|
local cfg = get_config()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Finding deduplication
|
### 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.
|
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
|
## 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.).
|
Plugin configuration is stored in a `plugins.yaml` file alongside the project database and can be edited through the "Proxy" TUI page.
|
||||||
|
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
|
||||||
|
|
||||||
`on_config` is called once at startup (before `on_start`) and again every time the user saves the config in the UI.
|
```yaml
|
||||||
|
plugins:
|
||||||
|
my_plugin:
|
||||||
|
enable: true
|
||||||
|
config:
|
||||||
|
some_key: some_value
|
||||||
|
list:
|
||||||
|
- item1
|
||||||
|
- item2
|
||||||
|
other_plugin:
|
||||||
|
enable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The config block is edited from the **Plugins** page in the TUI.
|
||||||
|
Inside a plugin, call `get_config()` to retrieve the config as a Lua table.
|
||||||
|
|
||||||
|
Global defaults for any plugin can be set in `~/.config/spilltea/config.yaml` under a `plugins` key with the same structure as `plugins.yaml`.
|
||||||
|
These defaults are applied the first time a plugin is loaded in a project; once the plugin has an entry in the project's `plugins.yaml`, the project config takes full precedence and the global defaults are ignored.
|
||||||
|
|
||||||
|
`on_config()` is called once at startup (before `on_start`) and again every time the user saves the config in the TUI.
|
||||||
|
It is the right place to read `get_config()` and populate local variables.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local items = {}
|
||||||
|
|
||||||
|
function on_config()
|
||||||
|
items = {}
|
||||||
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.list then
|
||||||
|
for _, v in ipairs(cfg.list) do
|
||||||
|
table.insert(items, v)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
## Sync vs async
|
## Sync vs async
|
||||||
|
|
||||||
@@ -128,28 +201,13 @@ Each plugin gets a **config textarea** on the Plugins page. The raw text is pass
|
|||||||
|
|
||||||
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
`on_config` and `on_quit` are always synchronous regardless of the Plugin table declaration.
|
||||||
|
|
||||||
### Return values for sync hooks
|
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.
|
||||||
**`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
|
## 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.
|
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
|
```lua
|
||||||
Plugin = {
|
Plugin = {
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Configuring your browser's proxy settings
|
||||||
|
|
||||||
|
**If you use Nix**, you can launch a pre-configured browser (LibreWolf) with the proxy and certificate already set up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:anotherhadi/spilltea#browser{{if or (ne .Cfg.App.Host "127.0.0.1") (ne .Cfg.App.Port 8080) (ne .Cfg.App.CertDir "~/.local/share/spilltea")}} --{{end}}{{if ne .Cfg.App.Host "127.0.0.1"}} --host {{.Cfg.App.Host}}{{end}}{{if ne .Cfg.App.Port 8080}} --port {{.Cfg.App.Port}}{{end}}{{if ne .Cfg.App.CertDir "~/.local/share/spilltea"}} --cert-dir {{.Cfg.App.CertDir}}{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, we recommend installing **FoxyProxy** to manage your browser's proxies.
|
||||||
|
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
|
||||||
|
|
||||||
|
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
|
||||||
|
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
|
||||||
|
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
|
||||||
|
4. You're all set! You can now use Spilltea.
|
||||||
|
|
||||||
|
If `proxy_auth` is set in the config (`user:pass`), enter the same credentials in FoxyProxy under "Username" and "Password" in the proxy settings.
|
||||||
Generated
+115
@@ -1,5 +1,103 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1767039857,
|
||||||
|
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778507602,
|
||||||
|
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gomod2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770585520,
|
||||||
|
"narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"rev": "1201ddd1279c35497754f016ef33d5e060f3da8d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "gomod2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1777954456,
|
||||||
@@ -18,8 +116,25 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"gomod2nix": "gomod2nix",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
{
|
{
|
||||||
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
description = "Spilltea: A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
|
||||||
inputs = {nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";};
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
gomod2nix = {
|
||||||
|
url = "github:nix-community/gomod2nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
git-hooks = {
|
||||||
|
url = "github:cachix/git-hooks.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
|
gomod2nix,
|
||||||
|
git-hooks,
|
||||||
}: let
|
}: let
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
|
||||||
forAllSystems = f:
|
forAllSystems = f:
|
||||||
nixpkgs.lib.genAttrs supportedSystems
|
nixpkgs.lib.genAttrs supportedSystems
|
||||||
(system: f system (import nixpkgs {inherit system;}));
|
(system: f system (import nixpkgs {inherit system;}));
|
||||||
|
|
||||||
pname = "spilltea";
|
|
||||||
version = "0.0.3";
|
|
||||||
|
|
||||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
|
||||||
in {
|
in {
|
||||||
packages = forAllSystems (system: pkgs: let
|
packages = forAllSystems (system: pkgs:
|
||||||
pkg = pkgs.buildGoModule {
|
import ./nix/package.nix {
|
||||||
inherit pname version ldflags;
|
inherit pkgs;
|
||||||
|
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||||
|
});
|
||||||
|
|
||||||
src = ./.;
|
devShells = forAllSystems (system: pkgs: {
|
||||||
outputs = ["out"];
|
default = import ./nix/shell.nix {
|
||||||
|
inherit pkgs;
|
||||||
vendorHash = "sha256-v37RFS/T6KGZTO1tHmtUqBrRcCqNS3+ACBcsd7tl50c=";
|
gitHooksLib = git-hooks.lib.${system};
|
||||||
|
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
|
||||||
homepage = "https://github.com/anotherhadi/spilltea";
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
in {
|
|
||||||
"${pname}" = pkg;
|
|
||||||
default = pkg;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,27 @@ require (
|
|||||||
charm.land/bubbletea/v2 v2.0.6
|
charm.land/bubbletea/v2 v2.0.6
|
||||||
charm.land/glamour/v2 v2.0.0
|
charm.land/glamour/v2 v2.0.0
|
||||||
charm.land/lipgloss/v2 v2.0.3
|
charm.land/lipgloss/v2 v2.0.3
|
||||||
|
github.com/andybalholm/brotli v1.0.4
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6
|
||||||
github.com/charmbracelet/x/ansi v0.11.7
|
github.com/charmbracelet/x/ansi v0.11.7
|
||||||
|
github.com/klauspost/compress v1.17.8
|
||||||
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
||||||
github.com/sirupsen/logrus v1.8.3
|
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.55.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 (
|
||||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/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 +35,38 @@ 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/inconshreveable/mousetrap v1.1.0 // 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/spf13/cobra v1.10.2 // 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.45.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
|||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY=
|
||||||
|
github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
@@ -24,14 +26,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=
|
||||||
@@ -42,7 +44,7 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
|||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
@@ -51,10 +53,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=
|
||||||
@@ -71,6 +73,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@@ -83,8 +87,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=
|
||||||
@@ -93,8 +97,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=
|
||||||
@@ -103,37 +107,36 @@ 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
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.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
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=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
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=
|
||||||
@@ -142,30 +145,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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.45.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
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=
|
||||||
@@ -174,18 +174,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=
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
type Colors struct {
|
|
||||||
Base00 string `mapstructure:"base00"`
|
|
||||||
Base01 string `mapstructure:"base01"`
|
|
||||||
Base02 string `mapstructure:"base02"`
|
|
||||||
Base03 string `mapstructure:"base03"`
|
|
||||||
Base04 string `mapstructure:"base04"`
|
|
||||||
Base05 string `mapstructure:"base05"`
|
|
||||||
Base06 string `mapstructure:"base06"`
|
|
||||||
Base07 string `mapstructure:"base07"`
|
|
||||||
Base08 string `mapstructure:"base08"`
|
|
||||||
Base09 string `mapstructure:"base09"`
|
|
||||||
Base0A string `mapstructure:"base0a"`
|
|
||||||
Base0B string `mapstructure:"base0b"`
|
|
||||||
Base0C string `mapstructure:"base0c"`
|
|
||||||
Base0D string `mapstructure:"base0d"`
|
|
||||||
Base0E string `mapstructure:"base0e"`
|
|
||||||
Base0F string `mapstructure:"base0f"`
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -24,10 +25,13 @@ type Config struct {
|
|||||||
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"`
|
UpstreamProxy string `mapstructure:"upstream_proxy"`
|
||||||
|
ProxyAuth string `mapstructure:"proxy_auth"`
|
||||||
|
MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
|
||||||
|
ExternalEditor string `mapstructure:"external_editor"`
|
||||||
|
SslInsecure bool `mapstructure:"ssl_insecure"`
|
||||||
} `mapstructure:"app"`
|
} `mapstructure:"app"`
|
||||||
|
|
||||||
TUI struct {
|
TUI struct {
|
||||||
Colors Colors `mapstructure:"colors"`
|
|
||||||
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
||||||
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
||||||
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
||||||
@@ -37,6 +41,7 @@ type Config struct {
|
|||||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||||
|
QueueSize int `mapstructure:"queue_size"`
|
||||||
} `mapstructure:"intercept"`
|
} `mapstructure:"intercept"`
|
||||||
|
|
||||||
Replay struct {
|
Replay struct {
|
||||||
@@ -45,13 +50,58 @@ 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"`
|
||||||
|
|
||||||
|
Plugins map[string]GlobalPlugin `mapstructure:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalPlugin struct {
|
||||||
|
Enable *bool `mapstructure:"enable"`
|
||||||
|
Config interface{} `mapstructure:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Config
|
var Global *Config
|
||||||
|
|
||||||
|
var projectCLIOverrides func(*Config)
|
||||||
|
|
||||||
|
// SetCLIOverrides stores a function that re-applies CLI flag overrides.
|
||||||
|
// It is called automatically after MergeProjectConfig so that CLI flags
|
||||||
|
// always win over both global and per-project config.
|
||||||
|
func SetCLIOverrides(fn func(*Config)) {
|
||||||
|
projectCLIOverrides = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeProjectConfig merges <projectDir>/config.yaml on top of the current
|
||||||
|
// Global config, then re-applies any registered CLI overrides.
|
||||||
|
// If no config.yaml exists in the project directory, this is a no-op.
|
||||||
|
func MergeProjectConfig(projectDir string) error {
|
||||||
|
projectConfigPath := filepath.Join(projectDir, "config.yaml")
|
||||||
|
if _, err := os.Stat(projectConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pv := viper.New()
|
||||||
|
pv.SetConfigFile(projectConfigPath)
|
||||||
|
if err := pv.ReadInConfig(); err != nil {
|
||||||
|
return fmt.Errorf("project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.MergeConfigMap(pv.AllSettings()); err != nil {
|
||||||
|
return fmt.Errorf("merge project config: %w", err)
|
||||||
|
}
|
||||||
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
if projectCLIOverrides != nil {
|
||||||
|
projectCLIOverrides(Global)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) error {
|
func Load(path string) error {
|
||||||
var defaults map[string]any
|
var defaults map[string]any
|
||||||
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
|
||||||
@@ -64,13 +114,28 @@ func Load(path string) error {
|
|||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
viper.SetConfigFile(path)
|
viper.SetConfigFile(path)
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Global = &Config{}
|
Global = &Config{}
|
||||||
return viper.Unmarshal(Global)
|
if err := viper.Unmarshal(Global); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
|
||||||
|
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteDefaultConfig(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExpandPath(p string) string {
|
func ExpandPath(p string) string {
|
||||||
@@ -84,6 +149,17 @@ func ExpandPath(p string) string {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandEnvValue replaces a value starting with "$" with the corresponding
|
||||||
|
// environment variable, enabling secrets to be kept out of config files.
|
||||||
|
func expandEnvValue(s string) string {
|
||||||
|
if len(s) > 1 && s[0] == '$' {
|
||||||
|
if val := os.Getenv(s[1:]); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func flatten(prefix string, m map[string]any) map[string]any {
|
func flatten(prefix string, m map[string]any) map[string]any {
|
||||||
out := make(map[string]any)
|
out := make(map[string]any)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ 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
|
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
|
||||||
|
proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled).
|
||||||
|
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
|
||||||
|
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||||
|
ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets)
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
default_intercept_enabled: true
|
default_intercept_enabled: true
|
||||||
default_capture_response: false
|
default_capture_response: false
|
||||||
|
queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
|
||||||
auto_forward_regex:
|
auto_forward_regex:
|
||||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||||
|
|
||||||
@@ -17,84 +22,84 @@ replay:
|
|||||||
|
|
||||||
history:
|
history:
|
||||||
skip_duplicates: true # 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: false
|
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:
|
|
||||||
base00: "110F12" # Default Background
|
|
||||||
base01: "1C1920" # Lighter Background (status bars, line numbers)
|
|
||||||
base02: "1D1A26" # Selection Background
|
|
||||||
base03: "514D63" # Comments, Invisibles, faint text
|
|
||||||
base04: "8E8AA0" # Dark Foreground (status bars)
|
|
||||||
base05: "C2BED6" # Default Foreground, Caret, Delimiters
|
|
||||||
base06: "D8D5EA" # Light Foreground (rarely used)
|
|
||||||
base07: "EAE7F7" # Light Background (rarely used)
|
|
||||||
base08: "E07080" # Red: errors, diff deleted
|
|
||||||
base09: "D49070" # Orange: integers, constants, warnings
|
|
||||||
base0a: "C4B060" # Yellow: classes, search highlight
|
|
||||||
base0b: "80B880" # Green: strings, diff inserted, success
|
|
||||||
base0c: "70B8C0" # Cyan: support, regex, escape chars
|
|
||||||
base0d: "9E97F8" # Blue/Accent: functions, headings, primary
|
|
||||||
base0e: "C090E8" # Purple: keywords, storage
|
|
||||||
base0f: "D080A0" # Pink: deprecated, embedded language tags
|
|
||||||
|
|
||||||
keybindings:
|
keybindings:
|
||||||
global:
|
global:
|
||||||
quit: "q,ctrl+c"
|
quit: "q,ctrl+c"
|
||||||
|
escape: "esc,ctrl+c"
|
||||||
|
help: "?"
|
||||||
open_logs: "ctrl+g"
|
open_logs: "ctrl+g"
|
||||||
toggle_sidebar: "ctrl+b"
|
toggle_sidebar: "ctrl+b"
|
||||||
help: "?"
|
cycle_focus: "tab"
|
||||||
|
send_to_replay: "ctrl+r"
|
||||||
|
send_to_diff: "ctrl+x"
|
||||||
|
copy_as: "ctrl+y"
|
||||||
|
copy: "y"
|
||||||
up: "up,k"
|
up: "up,k"
|
||||||
down: "down,j"
|
down: "down,j"
|
||||||
left: "left,h"
|
left: "left,h"
|
||||||
right: "right,l"
|
right: "right,l"
|
||||||
cycle_focus: "tab"
|
goto_top: "g"
|
||||||
copy_as: "ctrl+y"
|
goto_bottom: "G,end"
|
||||||
copy: "y"
|
scroll_up: "pgup,ctrl+u"
|
||||||
send_to_replay: "ctrl+r"
|
scroll_down: "pgdown,ctrl+d"
|
||||||
scroll_up: "pgup"
|
prev_page: "[,ctrl+i"
|
||||||
scroll_down: "pgdown"
|
next_page: "],ctrl+o"
|
||||||
send_to_diff: "ctrl+d"
|
|
||||||
|
|
||||||
intercept:
|
intercept:
|
||||||
|
toggle_intercept: "i"
|
||||||
|
capture_response: "r"
|
||||||
forward: "f"
|
forward: "f"
|
||||||
forward_all: "F"
|
forward_all: "F"
|
||||||
drop: "d"
|
drop: "d"
|
||||||
drop_all: "D"
|
drop_all: "D"
|
||||||
toggle_intercept: "i"
|
|
||||||
capture_response: "r"
|
|
||||||
undo_edits: "ctrl+z"
|
|
||||||
edit: "e,enter"
|
edit: "e,enter"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
|
undo_edits: "u,ctrl+z"
|
||||||
|
|
||||||
history:
|
history:
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
delete_all: "X"
|
delete_all: "X"
|
||||||
sql_query: ":"
|
sql_query: ":"
|
||||||
filter: "/"
|
filter: "/"
|
||||||
|
flag: "m"
|
||||||
|
|
||||||
home:
|
home:
|
||||||
open: "enter,l"
|
open: "l,enter"
|
||||||
delete: "x"
|
delete: "x"
|
||||||
filter: "/"
|
filter: "/"
|
||||||
|
|
||||||
replay:
|
replay:
|
||||||
send: "enter,s"
|
send: "s, enter"
|
||||||
edit: "e"
|
edit: "e"
|
||||||
edit_external: "E"
|
edit_external: "E"
|
||||||
undo_edits: "R"
|
undo_edits: "u,ctrl+z"
|
||||||
delete_entry: "x"
|
delete_entry: "x"
|
||||||
delete_all: "X"
|
delete_all: "X"
|
||||||
|
filter: "/"
|
||||||
|
flag: "m"
|
||||||
|
|
||||||
diff:
|
diff:
|
||||||
clear: "c"
|
clear: "x"
|
||||||
|
|
||||||
findings:
|
findings:
|
||||||
dismiss: "x"
|
dismiss: "x"
|
||||||
|
dismiss_all: "X"
|
||||||
|
flag: "m"
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
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"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
type GlobalKeys struct {
|
type GlobalKeys struct {
|
||||||
Quit string `mapstructure:"quit"`
|
Quit string `mapstructure:"quit"`
|
||||||
|
Escape string `mapstructure:"escape"`
|
||||||
OpenLogs string `mapstructure:"open_logs"`
|
OpenLogs string `mapstructure:"open_logs"`
|
||||||
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
ToggleSidebar string `mapstructure:"toggle_sidebar"`
|
||||||
Help string `mapstructure:"help"`
|
Help string `mapstructure:"help"`
|
||||||
@@ -16,6 +17,10 @@ type GlobalKeys struct {
|
|||||||
ScrollUp string `mapstructure:"scroll_up"`
|
ScrollUp string `mapstructure:"scroll_up"`
|
||||||
ScrollDown string `mapstructure:"scroll_down"`
|
ScrollDown string `mapstructure:"scroll_down"`
|
||||||
SendToDiff string `mapstructure:"send_to_diff"`
|
SendToDiff string `mapstructure:"send_to_diff"`
|
||||||
|
GotoTop string `mapstructure:"goto_top"`
|
||||||
|
GotoBottom string `mapstructure:"goto_bottom"`
|
||||||
|
PrevPage string `mapstructure:"prev_page"`
|
||||||
|
NextPage string `mapstructure:"next_page"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InterceptKeys struct {
|
type InterceptKeys struct {
|
||||||
@@ -35,6 +40,7 @@ type HistoryKeys struct {
|
|||||||
DeleteAll string `mapstructure:"delete_all"`
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
Filter string `mapstructure:"filter"`
|
Filter string `mapstructure:"filter"`
|
||||||
SqlQuery string `mapstructure:"sql_query"`
|
SqlQuery string `mapstructure:"sql_query"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomeKeys struct {
|
type HomeKeys struct {
|
||||||
@@ -50,6 +56,8 @@ type ReplayKeys struct {
|
|||||||
UndoEdits string `mapstructure:"undo_edits"`
|
UndoEdits string `mapstructure:"undo_edits"`
|
||||||
Delete string `mapstructure:"delete_entry"`
|
Delete string `mapstructure:"delete_entry"`
|
||||||
DeleteAll string `mapstructure:"delete_all"`
|
DeleteAll string `mapstructure:"delete_all"`
|
||||||
|
Filter string `mapstructure:"filter"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffKeys struct {
|
type DiffKeys struct {
|
||||||
@@ -58,6 +66,8 @@ type DiffKeys struct {
|
|||||||
|
|
||||||
type FindingsKeys struct {
|
type FindingsKeys struct {
|
||||||
Dismiss string `mapstructure:"dismiss"`
|
Dismiss string `mapstructure:"dismiss"`
|
||||||
|
DismissAll string `mapstructure:"dismiss_all"`
|
||||||
|
Flag string `mapstructure:"flag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginsKeys struct {
|
type PluginsKeys struct {
|
||||||
@@ -66,6 +76,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"`
|
||||||
@@ -75,4 +92,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-11
@@ -2,12 +2,17 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
type DB struct {
|
||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
|
roConn *sql.DB
|
||||||
|
path string
|
||||||
|
dedupMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*DB, error) {
|
func Open(path string) (*DB, error) {
|
||||||
@@ -15,15 +20,41 @@ func Open(path string) (*DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
d := &DB{conn: conn}
|
// SQLite only supports one concurrent writer; a pool of connections would
|
||||||
|
// cause SQLITE_BUSY errors when multiple proxy goroutines try to insert
|
||||||
|
// history entries at the same time.
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
d := &DB{conn: conn, path: path}
|
||||||
if err := d.migrate(); err != nil {
|
if err := d.migrate(); err != nil {
|
||||||
conn.Close()
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
roConn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
|
||||||
|
if cerr := conn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close conn: %v", cerr)
|
||||||
|
}
|
||||||
|
if cerr := roConn.Close(); cerr != nil {
|
||||||
|
log.Printf("db: close roConn: %v", cerr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.roConn = roConn
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) migrate() error {
|
func (d *DB) migrate() error {
|
||||||
|
if _, err := d.conn.Exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, err := d.conn.Exec(`
|
_, err := d.conn.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -33,7 +64,9 @@ func (d *DB) migrate() error {
|
|||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
request_raw TEXT NOT NULL,
|
request_raw TEXT NOT NULL,
|
||||||
response_raw TEXT NOT NULL
|
response_raw TEXT NOT NULL,
|
||||||
|
body_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS replay_entries (
|
CREATE TABLE IF NOT EXISTS replay_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -46,14 +79,10 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
request_raw TEXT NOT NULL,
|
request_raw TEXT NOT NULL,
|
||||||
response_raw TEXT NOT NULL,
|
response_raw TEXT NOT NULL,
|
||||||
status_code INTEGER NOT NULL,
|
status_code INTEGER NOT NULL,
|
||||||
error_msg TEXT NOT NULL
|
error_msg TEXT NOT NULL,
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS findings (
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
config_text TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS findings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
plugin_name TEXT NOT NULL,
|
plugin_name TEXT NOT NULL,
|
||||||
dedup_key TEXT NOT NULL,
|
dedup_key TEXT NOT NULL,
|
||||||
@@ -61,10 +90,15 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
severity TEXT NOT NULL DEFAULT 'info',
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
dismissed INTEGER NOT NULL DEFAULT 0,
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
UNIQUE(plugin_name, dedup_key)
|
UNIQUE(plugin_name, dedup_key)
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = d.conn.Exec(`CREATE INDEX IF NOT EXISTS idx_entries_dedup ON entries(method, host, path, body_hash)`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +112,7 @@ func (d *DB) Close() error {
|
|||||||
if d == nil {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
_ = d.roConn.Close()
|
||||||
return d.conn.Close()
|
return d.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +125,8 @@ func CountEntriesAt(path string) int {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
var n int
|
var n int
|
||||||
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
|
if err := conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
|
||||||
|
log.Printf("db: count entries: %v", err)
|
||||||
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-43
@@ -1,8 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -16,47 +18,57 @@ type Entry struct {
|
|||||||
StatusCode int
|
StatusCode int
|
||||||
RequestRaw string
|
RequestRaw string
|
||||||
ResponseRaw string
|
ResponseRaw string
|
||||||
|
Flagged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func bodyHash(body string) string {
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasDuplicate returns true if an entry with the same method, host, path and
|
// HasDuplicate returns true if an entry with the same method, host, path and
|
||||||
// request body already exists. Used to implement skip_duplicates filtering.
|
// request body hash already exists.
|
||||||
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
|
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
|
||||||
rows, err := d.conn.Query(
|
hash := bodyHash(body)
|
||||||
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`,
|
var exists int
|
||||||
method, host, path,
|
err := d.conn.QueryRow(
|
||||||
)
|
`SELECT 1 FROM entries WHERE method = ? AND host = ? AND path = ? AND body_hash = ? LIMIT 1`,
|
||||||
if err != nil {
|
method, host, path, hash,
|
||||||
return false, err
|
).Scan(&exists)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
return err == nil, err
|
||||||
for rows.Next() {
|
|
||||||
var raw string
|
|
||||||
if err := rows.Scan(&raw); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(raw, "\n\n", 2)
|
|
||||||
entryBody := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
entryBody = parts[1]
|
|
||||||
}
|
|
||||||
if entryBody == body {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) InsertEntry(e Entry) (Entry, error) {
|
// InsertIfNotDuplicate atomically checks for a duplicate and inserts if none
|
||||||
|
// exists. Returns (entry, isDuplicate, error).
|
||||||
|
func (d *DB) InsertIfNotDuplicate(e Entry, body string) (Entry, bool, error) {
|
||||||
|
d.dedupMu.Lock()
|
||||||
|
defer d.dedupMu.Unlock()
|
||||||
|
dup, err := d.HasDuplicate(e.Method, e.Host, e.Path, body)
|
||||||
|
if err != nil || dup {
|
||||||
|
return e, dup, err
|
||||||
|
}
|
||||||
|
e, err = d.InsertEntry(e, body)
|
||||||
|
return e, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw)
|
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw, body_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.Timestamp.UTC().Format(time.RFC3339),
|
e.Timestamp.UTC().Format(time.RFC3339),
|
||||||
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw,
|
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw, bodyHash(body),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
e.ID, _ = res.LastInsertId()
|
var idErr error
|
||||||
|
e.ID, idErr = res.LastInsertId()
|
||||||
|
if idErr != nil {
|
||||||
|
log.Printf("db: LastInsertId: %v", idErr)
|
||||||
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +77,16 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e Entry
|
||||||
var ts string
|
var ts string
|
||||||
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil {
|
var flagged int
|
||||||
|
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
@@ -76,7 +94,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
|
|
||||||
func (d *DB) ListEntries() ([]Entry, error) {
|
func (d *DB) ListEntries() ([]Entry, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
|
||||||
FROM entries ORDER BY id DESC`,
|
FROM entries ORDER BY id DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,7 +107,7 @@ func (d *DB) ListEntries() ([]Entry, error) {
|
|||||||
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||||
like := "%" + term + "%"
|
like := "%" + term + "%"
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
|
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged
|
||||||
FROM entries
|
FROM entries
|
||||||
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
|
||||||
ORDER BY id DESC`,
|
ORDER BY id DESC`,
|
||||||
@@ -102,17 +120,13 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryEntries executes a user-supplied query against the entries table.
|
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||||
// If the query does not start with SELECT, it is treated as a WHERE expression
|
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
|
||||||
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT).
|
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||||
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||||
q := strings.TrimSpace(rawSQL)
|
func (d *DB) QueryEntries(where string) ([]Entry, error) {
|
||||||
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") {
|
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) // #nosec G202 -- intentional: user-supplied WHERE clause on a PRAGMA query_only=ON connection
|
||||||
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q
|
rows, err := d.roConn.Query(q)
|
||||||
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
|
|
||||||
return nil, fmt.Errorf("only SELECT queries are allowed")
|
|
||||||
}
|
|
||||||
rows, err := d.conn.Query(q)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -120,6 +134,11 @@ func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE entries SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) DeleteEntry(id int64) error {
|
func (d *DB) DeleteEntry(id int64) error {
|
||||||
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
@@ -129,3 +148,18 @@ func (d *DB) DeleteAllEntries() error {
|
|||||||
_, err := d.conn.Exec(`DELETE FROM entries`)
|
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAllExceptFlagged deletes all unflagged entries. If there are no
|
||||||
|
// unflagged entries (only flagged ones remain), it deletes everything.
|
||||||
|
func (d *DB) DeleteAllExceptFlagged() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM entries WHERE flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
+28
-3
@@ -11,12 +11,15 @@ type Finding struct {
|
|||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Severity string
|
Severity string
|
||||||
|
Flagged bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
|
||||||
// not already exist. Returns true when the row was actually inserted.
|
// not already exist. Returns true when the row was actually inserted.
|
||||||
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
||||||
|
d.dedupMu.Lock()
|
||||||
|
defer d.dedupMu.Unlock()
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
VALUES (?, ?, ?, ?, ?, 0, ?)`,
|
||||||
@@ -32,8 +35,8 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
|||||||
|
|
||||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
`SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at
|
||||||
FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
|
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -43,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f Finding
|
var f Finding
|
||||||
var ts string
|
var ts string
|
||||||
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil {
|
var flagged int
|
||||||
|
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
f.Flagged = flagged != 0
|
||||||
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.Local()
|
f.CreatedAt = t.Local()
|
||||||
@@ -61,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error {
|
|||||||
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleFindingFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissAllFindings dismisses unflagged findings first. If none are unflagged
|
||||||
|
// (only flagged ones remain), it dismisses everything.
|
||||||
|
func (d *DB) DismissAllFindings() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
type PluginState struct {
|
|
||||||
Name string
|
|
||||||
Enabled bool
|
|
||||||
ConfigText string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
|
|
||||||
_, err := d.conn.Exec(
|
|
||||||
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
|
|
||||||
name, enabled, configText,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) LoadPluginStates() ([]PluginState, error) {
|
|
||||||
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []PluginState
|
|
||||||
for rows.Next() {
|
|
||||||
var s PluginState
|
|
||||||
var enabled int
|
|
||||||
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.Enabled = enabled != 0
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
+36
-8
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,16 +17,17 @@ type ReplayEntry struct {
|
|||||||
ResponseRaw string
|
ResponseRaw string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
ErrorMsg string
|
ErrorMsg string
|
||||||
|
Flagged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
||||||
res, err := d.conn.Exec(
|
res, err := d.conn.Exec(
|
||||||
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg)
|
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
e.Timestamp.UTC().Format(time.RFC3339),
|
e.Timestamp.UTC().Format(time.RFC3339),
|
||||||
e.Scheme, e.Host, e.Path, e.Method,
|
e.Scheme, e.Host, e.Path, e.Method,
|
||||||
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
|
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
|
||||||
e.StatusCode, e.ErrorMsg,
|
e.StatusCode, e.ErrorMsg, e.Flagged,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -35,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
|
|||||||
|
|
||||||
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
|
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
|
||||||
_, err := d.conn.Exec(
|
_, err := d.conn.Exec(
|
||||||
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`,
|
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
|
||||||
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID,
|
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
||||||
rows, err := d.conn.Query(
|
rows, err := d.conn.Query(
|
||||||
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg
|
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged
|
||||||
FROM replay_entries ORDER BY id ASC`,
|
FROM replay_entries ORDER BY id ASC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,16 +57,27 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e ReplayEntry
|
var e ReplayEntry
|
||||||
var ts string
|
var ts string
|
||||||
|
var flagged int
|
||||||
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
|
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
|
||||||
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil {
|
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
var tsErr error
|
||||||
|
e.Timestamp, tsErr = time.Parse(time.RFC3339, ts)
|
||||||
|
if tsErr != nil {
|
||||||
|
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
|
||||||
|
}
|
||||||
|
e.Flagged = flagged != 0
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleReplayFlag(id int64) error {
|
||||||
|
_, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) DeleteReplayEntry(id int64) error {
|
func (d *DB) DeleteReplayEntry(id int64) error {
|
||||||
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
|
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
|
||||||
return err
|
return err
|
||||||
@@ -74,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
|
|||||||
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If
|
||||||
|
// none are unflagged (only flagged ones remain), it deletes everything.
|
||||||
|
func (d *DB) DeleteAllReplayEntriesExceptFlagged() error {
|
||||||
|
var count int
|
||||||
|
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Icons struct {
|
|||||||
New string
|
New string
|
||||||
Temp string
|
Temp string
|
||||||
Project string
|
Project string
|
||||||
|
Flag string
|
||||||
}
|
}
|
||||||
|
|
||||||
var I *Icons
|
var I *Icons
|
||||||
@@ -44,8 +45,11 @@ func Init(cfg *config.Config) {
|
|||||||
New: " ",
|
New: " ",
|
||||||
Temp: " ",
|
Temp: " ",
|
||||||
Project: " ",
|
Project: " ",
|
||||||
|
Flag: " ",
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
I = &Icons{}
|
I = &Icons{
|
||||||
|
Flag: "*",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package intercept
|
package intercept
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -57,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewBroker() *Broker {
|
func NewBroker() *Broker {
|
||||||
|
size := config.Global.Intercept.QueueSize
|
||||||
|
if size <= 0 {
|
||||||
|
size = 64
|
||||||
|
}
|
||||||
b := &Broker{
|
b := &Broker{
|
||||||
Incoming: make(chan *PendingRequest, 64),
|
Incoming: make(chan *PendingRequest, size),
|
||||||
IncomingResponse: make(chan *PendingResponse, 64),
|
IncomingResponse: make(chan *PendingResponse, size),
|
||||||
}
|
}
|
||||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||||
return b
|
return b
|
||||||
@@ -75,9 +80,12 @@ func (b *Broker) SetCaptureResponse(v bool) {
|
|||||||
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
func (b *Broker) SetAutoForwardRegex(patterns []string) {
|
||||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
if r, err := regexp.Compile(p); err == nil {
|
r, err := regexp.Compile(p)
|
||||||
compiled = append(compiled, r)
|
if err != nil {
|
||||||
|
log.Printf("intercept: invalid auto_forward_regex %q: %v", p, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
compiled = append(compiled, r)
|
||||||
}
|
}
|
||||||
b.autoFwdMu.Lock()
|
b.autoFwdMu.Lock()
|
||||||
b.autoFwdRegexes = compiled
|
b.autoFwdRegexes = compiled
|
||||||
@@ -164,12 +172,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
if config.Global.History.SkipDuplicates {
|
|
||||||
body := string(r.Body)
|
body := string(r.Body)
|
||||||
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending := db.Entry{
|
pending := db.Entry{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
@@ -177,19 +180,38 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
|
|||||||
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 := b.onBeforeNewEntry; cb != nil {
|
||||||
if !cb(pending) {
|
if !cb(pending) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry, err := d.InsertEntry(pending)
|
var (
|
||||||
if err == nil {
|
entry db.Entry
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if config.Global.History.SkipDuplicates {
|
||||||
|
var dup bool
|
||||||
|
entry, dup, err = d.InsertIfNotDuplicate(pending, body)
|
||||||
|
if dup {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry, err = d.InsertEntry(pending, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("intercept: failed to save history entry: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cb := b.onNewEntry; cb != nil {
|
if cb := b.onNewEntry; cb != nil {
|
||||||
go cb(entry)
|
go cb(entry)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Broker) Decide(p *PendingRequest, d Decision) {
|
func (b *Broker) Decide(p *PendingRequest, d Decision) {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package intercept
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
"github.com/lqqyt2423/go-mitmproxy/proxy"
|
"github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,15 +14,11 @@ func FormatRawRequest(f *proxy.Flow) string {
|
|||||||
r := f.Request
|
r := f.Request
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||||
keys := make([]string, 0, len(r.Header))
|
if r.URL.Host != "" {
|
||||||
for k := range r.Header {
|
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
}
|
||||||
|
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||||
|
sb.WriteString(line)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if len(r.Body) > 0 {
|
if len(r.Body) > 0 {
|
||||||
@@ -43,15 +39,8 @@ func FormatRawResponse(f *proxy.Flow) string {
|
|||||||
proto = "HTTP/1.1"
|
proto = "HTTP/1.1"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
||||||
keys := make([]string, 0, len(r.Header))
|
for _, line := range util.SortedHeaderLines(r.Header) {
|
||||||
for k := range r.Header {
|
sb.WriteString(line)
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if len(r.Body) > 0 {
|
if len(r.Body) > 0 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -7,14 +7,18 @@ import (
|
|||||||
|
|
||||||
type FindingsKeyMap struct {
|
type FindingsKeyMap struct {
|
||||||
Dismiss key.Binding
|
Dismiss key.Binding
|
||||||
|
DismissAll key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
|
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
|
||||||
return FindingsKeyMap{
|
return FindingsKeyMap{
|
||||||
Dismiss: binding(cfg.Dismiss, "dismiss"),
|
Dismiss: binding(cfg.Dismiss, "dismiss"),
|
||||||
|
DismissAll: binding(cfg.DismissAll, "dismiss all"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FindingsKeyMap) Bindings() []key.Binding {
|
func (f FindingsKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{f.Dismiss}
|
return []key.Binding{f.Flag, f.Dismiss, f.DismissAll}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -22,6 +22,10 @@ type GlobalKeyMap struct {
|
|||||||
ScrollUp key.Binding
|
ScrollUp key.Binding
|
||||||
ScrollDown key.Binding
|
ScrollDown key.Binding
|
||||||
SendToDiff key.Binding
|
SendToDiff key.Binding
|
||||||
|
GotoTop key.Binding
|
||||||
|
GotoBottom key.Binding
|
||||||
|
PrevPage key.Binding
|
||||||
|
NextPage key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
||||||
@@ -37,11 +41,15 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
|
|||||||
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
|
||||||
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
CopyAs: binding(cfg.CopyAs, "copy as..."),
|
||||||
Copy: binding(cfg.Copy, "copy..."),
|
Copy: binding(cfg.Copy, "copy..."),
|
||||||
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
|
Escape: binding(cfg.Escape, "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"),
|
||||||
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
|
||||||
SendToDiff: binding(cfg.SendToDiff, "send to diff"),
|
SendToDiff: binding(cfg.SendToDiff, "send to diff"),
|
||||||
|
GotoTop: binding(cfg.GotoTop, "go to top"),
|
||||||
|
GotoBottom: binding(cfg.GotoBottom, "go to bottom"),
|
||||||
|
PrevPage: binding(cfg.PrevPage, "prev page"),
|
||||||
|
NextPage: binding(cfg.NextPage, "next page"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,5 +60,11 @@ func (g GlobalKeyMap) Bindings() []key.Binding {
|
|||||||
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
|
g.OpenLogs, g.ToggleSidebar, g.CopyAs, g.Copy,
|
||||||
g.SendToReplay, g.SendToDiff,
|
g.SendToReplay, g.SendToDiff,
|
||||||
g.ScrollUp, g.ScrollDown,
|
g.ScrollUp, g.ScrollDown,
|
||||||
|
g.GotoTop, g.GotoBottom, g.PrevPage, g.NextPage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommonBindings returns keys available on every page.
|
||||||
|
func (g GlobalKeyMap) CommonBindings() []key.Binding {
|
||||||
|
return []key.Binding{g.Quit, g.Help, g.OpenLogs, g.ToggleSidebar}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type HistoryKeyMap struct {
|
|||||||
DeleteAll key.Binding
|
DeleteAll key.Binding
|
||||||
Filter key.Binding
|
Filter key.Binding
|
||||||
SqlQuery key.Binding
|
SqlQuery key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
||||||
@@ -18,9 +19,10 @@ func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
|
|||||||
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
||||||
Filter: binding(cfg.Filter, "filter"),
|
Filter: binding(cfg.Filter, "filter"),
|
||||||
SqlQuery: binding(cfg.SqlQuery, "sql query"),
|
SqlQuery: binding(cfg.SqlQuery, "sql query"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h HistoryKeyMap) Bindings() []key.Binding {
|
func (h HistoryKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{h.DeleteEntry, h.DeleteAll}
|
return []key.Binding{h.DeleteEntry, h.DeleteAll, h.Flag}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type ReplayKeyMap struct {
|
|||||||
UndoEdits key.Binding
|
UndoEdits key.Binding
|
||||||
Delete key.Binding
|
Delete key.Binding
|
||||||
DeleteAll key.Binding
|
DeleteAll key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
Flag key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
||||||
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
|
|||||||
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
|
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
|
||||||
Delete: binding(cfg.Delete, "delete"),
|
Delete: binding(cfg.Delete, "delete"),
|
||||||
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
DeleteAll: binding(cfg.DeleteAll, "delete all"),
|
||||||
|
Filter: binding(cfg.Filter, "filter"),
|
||||||
|
Flag: binding(cfg.Flag, "flag"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r ReplayKeyMap) Bindings() []key.Binding {
|
func (r ReplayKeyMap) Bindings() []key.Binding {
|
||||||
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll}
|
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter}
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-11
@@ -1,17 +1,44 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
||||||
L := lua.NewState()
|
L := lua.NewState(lua.Options{SkipOpenLibs: true})
|
||||||
|
for _, lib := range []struct {
|
||||||
|
name string
|
||||||
|
fn lua.LGFunction
|
||||||
|
}{
|
||||||
|
{lua.BaseLibName, lua.OpenBase},
|
||||||
|
{lua.TabLibName, lua.OpenTable},
|
||||||
|
{lua.StringLibName, lua.OpenString},
|
||||||
|
{lua.MathLibName, lua.OpenMath},
|
||||||
|
{lua.CoroutineLibName, lua.OpenCoroutine},
|
||||||
|
} {
|
||||||
|
L.Push(L.NewFunction(lib.fn))
|
||||||
|
L.Push(lua.LString(lib.name))
|
||||||
|
L.Call(1, 0)
|
||||||
|
}
|
||||||
|
// Remove filesystem-access functions to prevent plugins from reading/executing arbitrary files.
|
||||||
|
for _, name := range []string{"dofile", "loadfile", "load"} {
|
||||||
|
L.SetGlobal(name, lua.LNil)
|
||||||
|
}
|
||||||
registerUtilities(L, mgr, p)
|
registerUtilities(L, mgr, p)
|
||||||
return L
|
return L
|
||||||
}
|
}
|
||||||
@@ -19,7 +46,7 @@ func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
|||||||
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||||
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
|
||||||
msg := L.CheckString(1)
|
msg := L.CheckString(1)
|
||||||
log.Printf("[plugin:%s] %s", p.Name, msg)
|
log.Printf("plugin %s: %s", p.Name, msg)
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -58,7 +85,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
|
log.Printf("plugin %s: create_finding error: %v", p.Name, err)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
_ = inserted
|
_ = inserted
|
||||||
@@ -153,6 +180,153 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
// p.mu is already held by the hook caller - do not lock again.
|
||||||
|
configText := p.ConfigText
|
||||||
|
if configText == "" {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
var data interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
lv := goToLuaValue(L, data)
|
||||||
|
if _, ok := lv.(*lua.LTable); !ok {
|
||||||
|
L.Push(L.NewTable())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lv)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("send_request", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
method := L.CheckString(1)
|
||||||
|
rawURL := L.CheckString(2)
|
||||||
|
hdrs := L.OptTable(3, L.NewTable())
|
||||||
|
body := L.OptString(4, "")
|
||||||
|
opts := L.OptTable(5, L.NewTable())
|
||||||
|
|
||||||
|
insecure := false
|
||||||
|
if v, ok := L.GetField(opts, "insecure").(lua.LBool); ok {
|
||||||
|
insecure = bool(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||||
|
}
|
||||||
|
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||||
|
if proxyURL, err := url.Parse(up); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
bodyReader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, rawURL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
hdrs.ForEach(func(k, v lua.LValue) {
|
||||||
|
ks, kok := k.(lua.LString)
|
||||||
|
vs, vok := v.(lua.LString)
|
||||||
|
if kok && vok {
|
||||||
|
req.Header.Set(string(ks), string(vs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
result := L.NewTable()
|
||||||
|
L.SetField(result, "status_code", lua.LNumber(resp.StatusCode))
|
||||||
|
L.SetField(result, "body", lua.LString(string(respBody)))
|
||||||
|
respHeaders := L.NewTable()
|
||||||
|
for k, vals := range resp.Header {
|
||||||
|
L.SetField(respHeaders, k, lua.LString(strings.Join(vals, ", ")))
|
||||||
|
}
|
||||||
|
L.SetField(result, "headers", respHeaders)
|
||||||
|
|
||||||
|
L.Push(result)
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 2
|
||||||
|
}))
|
||||||
|
|
||||||
|
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
cmd := L.CheckString(1)
|
||||||
|
input := L.OptString(2, "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c := exec.CommandContext(ctx, "sh", "-c", cmd) // #nosec G204 -- intentional: shell_pipe is a Lua plugin API for user-written scripts
|
||||||
|
c.Stdin = strings.NewReader(input)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
|
||||||
|
err := c.Run()
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LString(stdout.String()))
|
||||||
|
L.Push(lua.LString(err.Error() + ": " + stderr.String()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
L.Push(lua.LString(stdout.String()))
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 2
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToLuaValue(L *lua.LState, v interface{}) lua.LValue {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for k, v2 := range val {
|
||||||
|
L.SetField(t, k, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case []interface{}:
|
||||||
|
t := L.NewTable()
|
||||||
|
for i, v2 := range val {
|
||||||
|
L.RawSetInt(t, i+1, goToLuaValue(L, v2))
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
case string:
|
||||||
|
return lua.LString(val)
|
||||||
|
case int:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case float64:
|
||||||
|
return lua.LNumber(val)
|
||||||
|
case bool:
|
||||||
|
if val {
|
||||||
|
return lua.LTrue
|
||||||
|
}
|
||||||
|
return lua.LFalse
|
||||||
|
}
|
||||||
|
return lua.LNil
|
||||||
}
|
}
|
||||||
|
|
||||||
func luaTableString(t *lua.LTable, key string) string {
|
func luaTableString(t *lua.LTable, key string) string {
|
||||||
@@ -163,7 +337,7 @@ func luaTableString(t *lua.LTable, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
func pushRequest(L *lua.LState, p *Plugin, f *goproxy.Flow) *lua.LTable {
|
||||||
t := L.NewTable()
|
t := L.NewTable()
|
||||||
r := f.Request
|
r := f.Request
|
||||||
L.SetField(t, "method", lua.LString(r.Method))
|
L.SetField(t, "method", lua.LString(r.Method))
|
||||||
@@ -195,6 +369,21 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
|||||||
return 0
|
return 0
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
L.SetField(t, "set_path", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
r.URL.Path = L.CheckString(2)
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
L.SetField(t, "set_url", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
parsed, err := url.Parse(L.CheckString(2))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin %s: set_url: %v", p.Name, err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r.URL = parsed
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,22 +435,19 @@ func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) {
|
func callHook(p *Plugin, hookName string, args ...lua.LValue) (lua.LValue, error) {
|
||||||
fn := p.L.GetGlobal(hookName)
|
fn := p.L.GetGlobal(hookName)
|
||||||
if fn == lua.LNil {
|
if fn == lua.LNil {
|
||||||
return "", nil
|
return lua.LNil, nil
|
||||||
}
|
}
|
||||||
if err := p.L.CallByParam(lua.P{
|
if err := p.L.CallByParam(lua.P{
|
||||||
Fn: fn,
|
Fn: fn,
|
||||||
NRet: 1,
|
NRet: 1,
|
||||||
Protect: true,
|
Protect: true,
|
||||||
}, args...); err != nil {
|
}, args...); err != nil {
|
||||||
return "", err
|
return lua.LNil, err
|
||||||
}
|
}
|
||||||
ret := p.L.Get(-1)
|
ret := p.L.Get(-1)
|
||||||
p.L.Pop(1)
|
p.L.Pop(1)
|
||||||
if s, ok := ret.(lua.LString); ok {
|
return ret, nil
|
||||||
return string(s), nil
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-125
@@ -13,14 +13,23 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||||
lua "github.com/yuin/gopher-lua"
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GlobalDefault holds global defaults for a single plugin, read from config.yaml.
|
||||||
|
type GlobalDefault struct {
|
||||||
|
Enable *bool
|
||||||
|
Config interface{}
|
||||||
|
}
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins []*Plugin
|
plugins []*Plugin
|
||||||
|
|
||||||
db *db.DB
|
db *db.DB
|
||||||
|
pluginsFile *PluginsFile
|
||||||
broker *intercept.Broker
|
broker *intercept.Broker
|
||||||
|
globalDefaults map[string]GlobalDefault
|
||||||
|
|
||||||
Notifs chan PluginNotifMsg
|
Notifs chan PluginNotifMsg
|
||||||
Quit chan string
|
Quit chan string
|
||||||
@@ -43,6 +52,14 @@ func (m *Manager) SetDB(d *db.DB) {
|
|||||||
m.db = d
|
m.db = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetPluginsFile(pf *PluginsFile) {
|
||||||
|
m.pluginsFile = pf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetGlobalDefaults(defaults map[string]GlobalDefault) {
|
||||||
|
m.globalDefaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) LoadFromDir(dir string) error {
|
func (m *Manager) LoadFromDir(dir string) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -52,17 +69,6 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var states map[string]db.PluginState
|
|
||||||
if m.db != nil {
|
|
||||||
list, err := m.db.LoadPluginStates()
|
|
||||||
if err == nil {
|
|
||||||
states = make(map[string]db.PluginState, len(list))
|
|
||||||
for _, s := range list {
|
|
||||||
states[s.Name] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||||
continue
|
continue
|
||||||
@@ -73,19 +79,37 @@ func (m *Manager) LoadFromDir(dir string) error {
|
|||||||
log.Printf("plugin load error %s: %v", path, err)
|
log.Printf("plugin load error %s: %v", path, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s, ok := states[p.Name]; ok {
|
if m.pluginsFile != nil {
|
||||||
p.Enabled = s.Enabled
|
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
|
||||||
p.ConfigText = s.ConfigText
|
p.Enabled = enabled
|
||||||
|
p.ConfigText = configText
|
||||||
|
} else {
|
||||||
|
if def, ok := m.globalDefaults[p.ID]; ok {
|
||||||
|
if def.Enable != nil {
|
||||||
|
p.Enabled = *def.Enable
|
||||||
|
}
|
||||||
|
if def.Config != nil {
|
||||||
|
if raw, err := yaml.Marshal(def.Config); err == nil {
|
||||||
|
p.ConfigText = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.pluginsFile.register(p.ID, p.Enabled, p.ConfigText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.plugins = append(m.plugins, p)
|
m.plugins = append(m.plugins, p)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
sort.Slice(m.plugins, func(i, j int) bool { return m.plugins[i].Priority > m.plugins[j].Priority })
|
||||||
|
m.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||||
p := &Plugin{
|
p := &Plugin{
|
||||||
|
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
|
||||||
FilePath: path,
|
FilePath: path,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
hooks: make(map[string]HookConfig),
|
hooks: make(map[string]HookConfig),
|
||||||
@@ -106,7 +130,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Name = string(s)
|
p.Name = string(s)
|
||||||
}
|
}
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
|
p.Name = p.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
|
||||||
@@ -117,6 +141,10 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.Priority = int(n)
|
p.Priority = int(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pluginTable.RawGetString("disable_by_default") == lua.LTrue {
|
||||||
|
p.Enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks configurable via the Plugin table (sync field).
|
// Hooks configurable via the Plugin table (sync field).
|
||||||
configurableHooks := map[string]bool{
|
configurableHooks := map[string]bool{
|
||||||
"on_start": false, // async by default
|
"on_start": false, // async by default
|
||||||
@@ -124,12 +152,6 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
"on_response": false,
|
"on_response": false,
|
||||||
"on_history_entry": 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 {
|
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}
|
||||||
@@ -139,9 +161,9 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
|||||||
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
p.hooks[hookName] = HookConfig{Sync: defaultSync}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for hookName := range fixedSyncHooks {
|
for _, fixedSync := range []string{"on_config", "on_quit"} {
|
||||||
if p.L.GetGlobal(hookName) != lua.LNil {
|
if p.L.GetGlobal(fixedSync) != lua.LNil {
|
||||||
p.hooks[hookName] = HookConfig{Sync: true}
|
p.hooks[fixedSync] = HookConfig{Sync: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,15 +175,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) TogglePlugin(name string) {
|
func (m *Manager) TogglePlugin(id string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -173,18 +194,57 @@ func (m *Manager) TogglePlugin(name string) {
|
|||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.Enabled = !found.Enabled
|
found.Enabled = !found.Enabled
|
||||||
enabled := found.Enabled
|
enabled := found.Enabled
|
||||||
configText := found.ConfigText
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hc, ok := found.hooks["on_start"]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
|
if ret == lua.LFalse {
|
||||||
|
p.Enabled = false
|
||||||
|
if m.pluginsFile != nil {
|
||||||
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hc.Sync {
|
||||||
|
found.mu.Lock()
|
||||||
|
ret, err := callHook(found, "on_start")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin %s on_start: %v", found.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(found, ret)
|
||||||
|
}
|
||||||
|
found.mu.Unlock()
|
||||||
|
} else {
|
||||||
|
go func() {
|
||||||
|
found.mu.Lock()
|
||||||
|
ret, err := callHook(found, "on_start")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin %s on_start: %v", found.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(found, ret)
|
||||||
|
}
|
||||||
|
found.mu.Unlock()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) SaveConfig(name, configText string) {
|
func (m *Manager) SaveConfig(id, configText string) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
var found *Plugin
|
var found *Plugin
|
||||||
for _, p := range m.plugins {
|
for _, p := range m.plugins {
|
||||||
if p.Name == name {
|
if p.ID == id {
|
||||||
found = p
|
found = p
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -195,39 +255,35 @@ func (m *Manager) SaveConfig(name, configText string) {
|
|||||||
}
|
}
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
found.ConfigText = configText
|
found.ConfigText = configText
|
||||||
enabled := found.Enabled
|
|
||||||
_, hasOnConfig := found.hooks["on_config"]
|
|
||||||
found.mu.Unlock()
|
found.mu.Unlock()
|
||||||
if m.db != nil {
|
if m.pluginsFile != nil {
|
||||||
_ = m.db.SavePluginState(name, enabled, configText)
|
if err := m.pluginsFile.setConfig(id, configText); err != nil {
|
||||||
|
log.Printf("plugin %s: save config: %v", id, err)
|
||||||
}
|
}
|
||||||
if !hasOnConfig {
|
}
|
||||||
|
if _, ok := found.hooks["on_config"]; !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// on_config is always sync.
|
|
||||||
found.mu.Lock()
|
found.mu.Lock()
|
||||||
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
if _, err := callHook(found, "on_config"); err != nil {
|
||||||
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
log.Printf("plugin %s on_config: %v", id, err)
|
||||||
}
|
}
|
||||||
found.mu.Unlock()
|
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_config"]; !ok {
|
if _, ok := p.hooks["on_config"]; ok {
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
|
if _, err := callHook(p, "on_config"); err != nil {
|
||||||
log.Printf("plugin %s on_config: %v", p.Name, err)
|
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
// on_start runs after, sync or async depending on plugin config.
|
}
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
@@ -236,17 +292,33 @@ func (m *Manager) RunOnStart() {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||||
|
if ret == lua.LFalse {
|
||||||
|
p.Enabled = false
|
||||||
|
if m.pluginsFile != nil {
|
||||||
|
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||||
|
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if hc.Sync {
|
if hc.Sync {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if _, err := callHook(p, "on_start"); err != nil {
|
ret, err := callHook(p, "on_start")
|
||||||
|
if err != nil {
|
||||||
log.Printf("plugin %s on_start: %v", p.Name, err)
|
log.Printf("plugin %s on_start: %v", p.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(p, ret)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
} else {
|
} else {
|
||||||
go func(p *Plugin) {
|
go func(p *Plugin) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if _, err := callHook(p, "on_start"); err != nil {
|
ret, err := callHook(p, "on_start")
|
||||||
|
if err != nil {
|
||||||
log.Printf("plugin %s on_start: %v", p.Name, err)
|
log.Printf("plugin %s on_start: %v", p.Name, err)
|
||||||
|
} else {
|
||||||
|
disableIfFalse(p, ret)
|
||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
}(p)
|
}(p)
|
||||||
@@ -270,94 +342,79 @@ func (m *Manager) RunOnQuit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
// runSyncDecisionForPlugins runs hookName synchronously for all enabled plugins
|
||||||
|
// that registered it as sync, and returns the first non-Intercept decision.
|
||||||
|
func (m *Manager) runSyncDecisionForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) intercept.Decision {
|
||||||
for _, p := range m.GetPlugins() {
|
for _, p := range m.GetPlugins() {
|
||||||
if !p.Enabled {
|
if !p.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hc, ok := p.hooks["on_request"]
|
hc, ok := p.hooks[hookName]
|
||||||
if !ok || !hc.Sync {
|
if !ok || !hc.Sync {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
result, err := callHook(p, "on_request", pushRequest(p.L, f))
|
result, err := callHook(p, hookName, argsFor(p)...)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("plugin %s on_request: %v", p.Name, err)
|
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch result {
|
if s, ok := result.(lua.LString); ok {
|
||||||
|
switch string(s) {
|
||||||
case "drop":
|
case "drop":
|
||||||
return intercept.Drop
|
return intercept.Drop
|
||||||
case "forward":
|
case "forward":
|
||||||
return intercept.Forward
|
return intercept.Forward
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return intercept.Intercept
|
return intercept.Intercept
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runAsyncForPlugins fires hookName asynchronously for all enabled plugins
|
||||||
|
// that registered it as async.
|
||||||
|
func (m *Manager) runAsyncForPlugins(hookName string, argsFor func(*Plugin) []lua.LValue) {
|
||||||
|
for _, p := range m.GetPlugins() {
|
||||||
|
if !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hc, ok := p.hooks[hookName]
|
||||||
|
if !ok || hc.Sync {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func(p *Plugin) {
|
||||||
|
p.mu.Lock()
|
||||||
|
if _, err := callHook(p, hookName, argsFor(p)...); err != nil {
|
||||||
|
log.Printf("plugin %s %s: %v", p.Name, hookName, err)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
|
||||||
|
return m.runSyncDecisionForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_request", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, p, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_request"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
|
|
||||||
log.Printf("plugin %s on_request: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
|
||||||
for _, p := range m.GetPlugins() {
|
return m.runSyncDecisionForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_response"]
|
|
||||||
if !ok || !hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
|
|
||||||
p.mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("plugin %s on_response: %v", p.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch result {
|
|
||||||
case "drop":
|
|
||||||
return intercept.Drop
|
|
||||||
case "forward":
|
|
||||||
return intercept.Forward
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return intercept.Intercept
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_response", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushRequest(p.L, p, f), pushResponse(p.L, f)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_response"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
|
|
||||||
log.Printf("plugin %s on_response: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
|
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
|
||||||
@@ -377,7 +434,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
|||||||
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if result == "skip" {
|
if s, ok := result.(lua.LString); ok && string(s) == "skip" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,20 +442,7 @@ func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
|
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
|
||||||
for _, p := range m.GetPlugins() {
|
m.runAsyncForPlugins("on_history_entry", func(p *Plugin) []lua.LValue {
|
||||||
if !p.Enabled {
|
return []lua.LValue{pushEntry(p.L, e)}
|
||||||
continue
|
})
|
||||||
}
|
|
||||||
hc, ok := p.hooks["on_history_entry"]
|
|
||||||
if !ok || hc.Sync {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(p *Plugin) {
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
|
|
||||||
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
}(p)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pluginFileEntry struct {
|
||||||
|
Enable bool `yaml:"enable"`
|
||||||
|
Config interface{} `yaml:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pluginsFileData struct {
|
||||||
|
Plugins map[string]pluginFileEntry `yaml:"plugins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsFile struct {
|
||||||
|
path string
|
||||||
|
data pluginsFileData
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPluginsFile(dbPath string) (*PluginsFile, error) {
|
||||||
|
path := filepath.Join(filepath.Dir(dbPath), "plugins.yaml")
|
||||||
|
pf := &PluginsFile{
|
||||||
|
path: path,
|
||||||
|
data: pluginsFileData{Plugins: make(map[string]pluginFileEntry)},
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(path) // #nosec G304 -- path is filepath.Join(dbDir, "plugins.yaml"), hardcoded filename
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &pf.data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pf.data.Plugins == nil {
|
||||||
|
pf.data.Plugins = make(map[string]pluginFileEntry)
|
||||||
|
}
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) save() error {
|
||||||
|
raw, err := yaml.Marshal(&pf.data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(pf.path, raw, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) get(id string) (enabled bool, config string, found bool) {
|
||||||
|
e, ok := pf.data.Plugins[id]
|
||||||
|
if !ok {
|
||||||
|
return false, "", false
|
||||||
|
}
|
||||||
|
if e.Config == nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
raw, err := yaml.Marshal(e.Config)
|
||||||
|
if err != nil {
|
||||||
|
return e.Enable, "", true
|
||||||
|
}
|
||||||
|
return e.Enable, string(raw), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) register(id string, defaultEnabled bool, configText string) {
|
||||||
|
if _, ok := pf.data.Plugins[id]; !ok {
|
||||||
|
entry := pluginFileEntry{Enable: defaultEnabled}
|
||||||
|
if configText != "" {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &parsed); err == nil {
|
||||||
|
entry.Config = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pf.data.Plugins[id] = entry
|
||||||
|
if err := pf.save(); err != nil {
|
||||||
|
log.Printf("plugin %s: register save: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setEnabled(id string, enabled bool) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
e.Enable = enabled
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pf *PluginsFile) setConfig(id string, configText string) error {
|
||||||
|
e := pf.data.Plugins[id]
|
||||||
|
if configText == "" {
|
||||||
|
e.Config = nil
|
||||||
|
} else {
|
||||||
|
var parsed interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(configText), &parsed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Config = parsed
|
||||||
|
}
|
||||||
|
pf.data.Plugins[id] = e
|
||||||
|
return pf.save()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ type HookConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Plugin struct {
|
type Plugin struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
|
|||||||
hooks[k] = v
|
hooks[k] = v
|
||||||
}
|
}
|
||||||
return Info{
|
return Info{
|
||||||
|
ID: p.ID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
FilePath: p.FilePath,
|
FilePath: p.FilePath,
|
||||||
|
|||||||
+48
-3
@@ -1,10 +1,14 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
@@ -43,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
switch a.plugins.RunSyncOnRequest(f) {
|
switch a.plugins.RunSyncOnRequest(f) {
|
||||||
case intercept.Drop:
|
case intercept.Drop:
|
||||||
f.Response = dropResponse()
|
f.Response = dropResponse()
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
|
||||||
return
|
return
|
||||||
case intercept.Forward:
|
case intercept.Forward:
|
||||||
go a.plugins.RunAsyncOnRequest(f)
|
go a.plugins.RunAsyncOnRequest(f)
|
||||||
@@ -63,7 +66,15 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
|||||||
func (a *interceptAddon) Response(f *goproxy.Flow) {
|
func (a *interceptAddon) Response(f *goproxy.Flow) {
|
||||||
if f.Response != nil {
|
if f.Response != nil {
|
||||||
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
|
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
|
||||||
body, _ := io.ReadAll(f.Response.BodyReader)
|
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
||||||
|
body, err := io.ReadAll(io.LimitReader(f.Response.BodyReader, limit))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("proxy: reading response body: %v", err)
|
||||||
|
}
|
||||||
|
if int64(len(body)) == limit {
|
||||||
|
log.Printf("proxy: response body truncated at %dMB for %s", config.Global.App.MaxBodySizeMB, f.Request.URL.Host)
|
||||||
|
body = append(body, []byte(fmt.Sprintf("\n\n[body truncated at %dMB]", config.Global.App.MaxBodySizeMB))...)
|
||||||
|
}
|
||||||
f.Response.Body = body
|
f.Response.Body = body
|
||||||
f.Response.BodyReader = nil
|
f.Response.BodyReader = nil
|
||||||
}
|
}
|
||||||
@@ -106,9 +117,10 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
|
|
||||||
opts := &goproxy.Options{
|
opts := &goproxy.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
StreamLargeBodies: 1024 * 1024 * 5,
|
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
|
||||||
CaRootPath: caPath,
|
CaRootPath: caPath,
|
||||||
Upstream: cfg.UpstreamProxy,
|
Upstream: cfg.UpstreamProxy,
|
||||||
|
SslInsecure: cfg.SslInsecure,
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := goproxy.NewProxy(opts)
|
p, err := goproxy.NewProxy(opts)
|
||||||
@@ -116,10 +128,43 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ProxyAuth != "" {
|
||||||
|
parts := strings.SplitN(cfg.ProxyAuth, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
wantUser, wantPass := parts[0], parts[1]
|
||||||
|
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||||
|
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||||
|
userOK := subtle.ConstantTimeCompare([]byte(user), []byte(wantUser))
|
||||||
|
passOK := subtle.ConstantTimeCompare([]byte(pass), []byte(wantPass))
|
||||||
|
if !ok || userOK&passOK != 1 {
|
||||||
|
res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
|
||||||
|
return false, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
|
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
|
||||||
return p.Start()
|
return p.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBasicProxyAuth(header string) (user, pass string, ok bool) {
|
||||||
|
const prefix = "Basic "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(header[len(prefix):])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], true
|
||||||
|
}
|
||||||
|
|
||||||
func dropResponse() *goproxy.Response {
|
func dropResponse() *goproxy.Response {
|
||||||
return &goproxy.Response{
|
return &goproxy.Response{
|
||||||
StatusCode: 502,
|
StatusCode: 502,
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PanelContentH returns the usable inner content height for a panel rendered by
|
|
||||||
// RenderWithTitle. It subtracts the two border lines (top + bottom) from the
|
|
||||||
// total panel height.
|
|
||||||
func PanelContentH(totalH int) int {
|
|
||||||
h := totalH - 2
|
|
||||||
if h < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderWithTitle renders a lipgloss bordered box with a title embedded in the
|
|
||||||
// top border, matching the border's own foreground color. height is the total
|
|
||||||
// desired output height (including both border lines).
|
|
||||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
|
||||||
boxH := height - 1
|
|
||||||
if contentH := boxH - 1; contentH > 0 {
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if len(lines) > contentH {
|
|
||||||
content = strings.Join(lines[:contentH], "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
|
|
||||||
|
|
||||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
|
||||||
label := " " + title + " "
|
|
||||||
fillW := boxWidth - lipgloss.Width(label) - 2
|
|
||||||
if fillW < 0 {
|
|
||||||
fillW = 0
|
|
||||||
}
|
|
||||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
|
||||||
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
|
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,36 @@
|
|||||||
package style
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/paginator"
|
|
||||||
"charm.land/bubbles/v2/textarea"
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewViewport() viewport.Model {
|
|
||||||
vp := viewport.New()
|
|
||||||
vp.MouseWheelEnabled = false
|
|
||||||
return vp
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPaginator() paginator.Model {
|
|
||||||
p := paginator.New()
|
|
||||||
p.Type = paginator.Dots
|
|
||||||
p.ActiveDot = S.PagerDotActive
|
|
||||||
p.InactiveDot = S.PagerDotInactive
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTextarea(showLineNumbers bool) textarea.Model {
|
|
||||||
ta := textarea.New()
|
|
||||||
ta.Prompt = ""
|
|
||||||
ta.ShowLineNumbers = showLineNumbers
|
|
||||||
ta.CharLimit = 0
|
|
||||||
ta.EndOfBufferCharacter = '~'
|
|
||||||
ts := ta.Styles()
|
|
||||||
ts.Focused.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.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.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
|
|
||||||
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)
|
|
||||||
return ta
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
|
|
||||||
func SeverityStyle(sev string) lipgloss.Style {
|
func SeverityStyle(sev string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true)
|
base := lipgloss.NewStyle().Bold(true)
|
||||||
switch sev {
|
switch sev {
|
||||||
case "critical":
|
case "critical":
|
||||||
return base.Foreground(S.Error)
|
return base.Foreground(ilovetui.S.Error)
|
||||||
case "high":
|
case "high":
|
||||||
return base.Foreground(S.Warning)
|
return base.Foreground(ilovetui.S.Warning)
|
||||||
case "medium":
|
case "medium":
|
||||||
return base.Foreground(S.Primary)
|
return base.Foreground(ilovetui.S.Primary)
|
||||||
case "low":
|
case "low":
|
||||||
return base.Foreground(S.Success)
|
return base.Foreground(ilovetui.S.Success)
|
||||||
default:
|
default:
|
||||||
return base.Foreground(S.Subtle)
|
return base.Foreground(ilovetui.S.Subtle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
|
|
||||||
func StatusStyle(code, width int) lipgloss.Style {
|
func StatusStyle(code, width int) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(width)
|
base := lipgloss.NewStyle().Bold(true).Width(width)
|
||||||
switch {
|
switch {
|
||||||
case code >= 500:
|
case code >= 500:
|
||||||
return base.Foreground(S.Error)
|
return base.Foreground(ilovetui.S.Error)
|
||||||
case code >= 400:
|
case code >= 400:
|
||||||
return base.Foreground(S.Warning)
|
return base.Foreground(ilovetui.S.Warning)
|
||||||
case code >= 300:
|
case code >= 300:
|
||||||
return base.Foreground(S.Primary)
|
return base.Foreground(ilovetui.S.Primary)
|
||||||
default:
|
default:
|
||||||
return base.Foreground(S.Success)
|
return base.Foreground(ilovetui.S.Success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SplitH splits totalHeight into top and bottom sections, accounting for the
|
|
||||||
// status bar height.
|
|
||||||
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
|
|
||||||
statusH := strings.Count(statusBar, "\n") + 1
|
|
||||||
available := totalHeight - statusH
|
|
||||||
top = int(float64(available) * ratio)
|
|
||||||
bottom = available - top
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
package style
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
|
||||||
|
|
||||||
"charm.land/glamour/v2/ansi"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig {
|
|
||||||
c := cfg.TUI.Colors
|
|
||||||
|
|
||||||
str := func(s string) *string { return &s }
|
|
||||||
hex := func(base string) *string { return str("#" + base) }
|
|
||||||
boolPtr := func(b bool) *bool { return &b }
|
|
||||||
uintPtr := func(u uint) *uint { return &u }
|
|
||||||
|
|
||||||
return ansi.StyleConfig{
|
|
||||||
Document: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "\n",
|
|
||||||
BlockSuffix: "\n",
|
|
||||||
Color: hex(c.Base05),
|
|
||||||
},
|
|
||||||
Margin: uintPtr(2),
|
|
||||||
},
|
|
||||||
BlockQuote: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base03),
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
Indent: uintPtr(1),
|
|
||||||
IndentToken: str("│ "),
|
|
||||||
},
|
|
||||||
List: ansi.StyleList{
|
|
||||||
LevelIndent: 2,
|
|
||||||
},
|
|
||||||
Heading: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
BlockSuffix: "\n",
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H1: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: " ",
|
|
||||||
Suffix: " ",
|
|
||||||
Color: hex(c.Base07),
|
|
||||||
BackgroundColor: hex(c.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H2: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "## ",
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H3: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "### ",
|
|
||||||
Color: hex(c.Base0C),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H4: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "#### ",
|
|
||||||
Color: hex(c.Base0B),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H5: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "##### ",
|
|
||||||
Color: hex(c.Base09),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
H6: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: "###### ",
|
|
||||||
Color: hex(c.Base08),
|
|
||||||
Bold: boolPtr(false),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Strikethrough: ansi.StylePrimitive{
|
|
||||||
CrossedOut: boolPtr(true),
|
|
||||||
},
|
|
||||||
Emph: ansi.StylePrimitive{
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
Strong: ansi.StylePrimitive{
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
HorizontalRule: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base03),
|
|
||||||
Format: "\n--------\n",
|
|
||||||
},
|
|
||||||
Item: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "• ",
|
|
||||||
},
|
|
||||||
Enumeration: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: ". ",
|
|
||||||
},
|
|
||||||
Task: ansi.StyleTask{
|
|
||||||
Ticked: "[✓] ",
|
|
||||||
Unticked: "[ ] ",
|
|
||||||
},
|
|
||||||
Link: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0C),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
LinkText: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
Image: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0C),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
ImageText: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base04),
|
|
||||||
Format: "Image: {{.text}} ->",
|
|
||||||
},
|
|
||||||
Code: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Prefix: " ",
|
|
||||||
Suffix: " ",
|
|
||||||
Color: hex(c.Base0B),
|
|
||||||
BackgroundColor: hex(c.Base01),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
CodeBlock: ansi.StyleCodeBlock{
|
|
||||||
StyleBlock: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base04),
|
|
||||||
},
|
|
||||||
Margin: uintPtr(2),
|
|
||||||
},
|
|
||||||
Chroma: &ansi.Chroma{
|
|
||||||
Text: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base05),
|
|
||||||
},
|
|
||||||
Error: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base07),
|
|
||||||
BackgroundColor: hex(c.Base08),
|
|
||||||
},
|
|
||||||
Comment: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base03),
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
CommentPreproc: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base09),
|
|
||||||
},
|
|
||||||
Keyword: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0E),
|
|
||||||
},
|
|
||||||
KeywordReserved: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0E),
|
|
||||||
},
|
|
||||||
KeywordNamespace: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
},
|
|
||||||
KeywordType: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0A),
|
|
||||||
},
|
|
||||||
Operator: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base05),
|
|
||||||
},
|
|
||||||
Punctuation: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base05),
|
|
||||||
},
|
|
||||||
Name: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base05),
|
|
||||||
},
|
|
||||||
NameBuiltin: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
},
|
|
||||||
NameTag: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base08),
|
|
||||||
},
|
|
||||||
NameAttribute: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0A),
|
|
||||||
},
|
|
||||||
NameClass: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0A),
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
Underline: boolPtr(true),
|
|
||||||
},
|
|
||||||
NameConstant: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base09),
|
|
||||||
},
|
|
||||||
NameDecorator: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0C),
|
|
||||||
},
|
|
||||||
NameFunction: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0D),
|
|
||||||
},
|
|
||||||
LiteralNumber: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base09),
|
|
||||||
},
|
|
||||||
LiteralString: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0B),
|
|
||||||
},
|
|
||||||
LiteralStringEscape: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0C),
|
|
||||||
},
|
|
||||||
GenericDeleted: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base08),
|
|
||||||
},
|
|
||||||
GenericEmph: ansi.StylePrimitive{
|
|
||||||
Italic: boolPtr(true),
|
|
||||||
},
|
|
||||||
GenericInserted: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base0B),
|
|
||||||
},
|
|
||||||
GenericStrong: ansi.StylePrimitive{
|
|
||||||
Bold: boolPtr(true),
|
|
||||||
},
|
|
||||||
GenericSubheading: ansi.StylePrimitive{
|
|
||||||
Color: hex(c.Base04),
|
|
||||||
},
|
|
||||||
Background: ansi.StylePrimitive{
|
|
||||||
BackgroundColor: hex(c.Base01),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Table: ansi.StyleTable{
|
|
||||||
StyleBlock: ansi.StyleBlock{
|
|
||||||
StylePrimitive: ansi.StylePrimitive{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DefinitionDescription: ansi.StylePrimitive{
|
|
||||||
BlockPrefix: "\n> ",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
-26
@@ -3,22 +3,23 @@ package style
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"image/color"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
|
"image/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Paint(c color.Color, s string) string {
|
func Paint(c color.Color, s string) string {
|
||||||
return lipgloss.NewStyle().Foreground(c).Render(s)
|
return lipgloss.NewStyle().Foreground(c).Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HighlightHTTP highlights a full raw HTTP message (headers + body).
|
|
||||||
func HighlightHTTP(raw string) string {
|
func HighlightHTTP(raw string) string {
|
||||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
idx := strings.Index(raw, "\n\n")
|
idx := strings.Index(raw, "\n\n")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return highlightHeaders(raw)
|
return highlightHeaders(raw)
|
||||||
@@ -75,8 +76,8 @@ func highlightHeaders(raw string) string {
|
|||||||
} else if trimmed == "" {
|
} else if trimmed == "" {
|
||||||
out.WriteString(line)
|
out.WriteString(line)
|
||||||
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
|
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
|
||||||
out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
|
out.WriteString(Paint(ilovetui.S.Subtle, trimmed[:idx+2]))
|
||||||
out.WriteString(Paint(S.Text, trimmed[idx+2:]))
|
out.WriteString(Paint(ilovetui.S.Text, trimmed[idx+2:]))
|
||||||
} else {
|
} else {
|
||||||
out.WriteString(line)
|
out.WriteString(line)
|
||||||
}
|
}
|
||||||
@@ -95,16 +96,16 @@ func highlightStatusLine(line string) string {
|
|||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
||||||
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
|
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
|
||||||
result += Paint(S.Primary, parts[1])
|
result += Paint(ilovetui.S.Primary, parts[1])
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
result += " " + Paint(S.Subtle, parts[2])
|
result += " " + Paint(ilovetui.S.Subtle, parts[2])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
result := Paint(S.Subtle, parts[0]) + " "
|
result := Paint(ilovetui.S.Subtle, parts[0]) + " "
|
||||||
result += Paint(S.Warning, parts[1])
|
result += Paint(ilovetui.S.Warning, parts[1])
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
result += " " + Paint(S.MutedFg, parts[2])
|
result += " " + Paint(ilovetui.S.Muted, parts[2])
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -134,9 +135,9 @@ func highlightJSON(s string) string {
|
|||||||
k++
|
k++
|
||||||
}
|
}
|
||||||
if k < n && s[k] == ':' {
|
if k < n && s[k] == ':' {
|
||||||
out.WriteString(Paint(S.Primary, str))
|
out.WriteString(Paint(ilovetui.S.Primary, str))
|
||||||
} else {
|
} else {
|
||||||
out.WriteString(Paint(S.Success, str))
|
out.WriteString(Paint(ilovetui.S.Success, str))
|
||||||
}
|
}
|
||||||
i = j
|
i = j
|
||||||
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
|
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
|
||||||
@@ -147,19 +148,19 @@ func highlightJSON(s string) string {
|
|||||||
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
|
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
case i+4 <= n && s[i:i+4] == "true":
|
case i+4 <= n && s[i:i+4] == "true":
|
||||||
out.WriteString(Paint(S.Error, "true"))
|
out.WriteString(Paint(ilovetui.S.Error, "true"))
|
||||||
i += 4
|
i += 4
|
||||||
case i+5 <= n && s[i:i+5] == "false":
|
case i+5 <= n && s[i:i+5] == "false":
|
||||||
out.WriteString(Paint(S.Error, "false"))
|
out.WriteString(Paint(ilovetui.S.Error, "false"))
|
||||||
i += 5
|
i += 5
|
||||||
case i+4 <= n && s[i:i+4] == "null":
|
case i+4 <= n && s[i:i+4] == "null":
|
||||||
out.WriteString(Paint(S.Error, "null"))
|
out.WriteString(Paint(ilovetui.S.Error, "null"))
|
||||||
i += 4
|
i += 4
|
||||||
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
|
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
|
||||||
out.WriteString(Paint(S.Subtle, string(ch)))
|
out.WriteString(Paint(ilovetui.S.Subtle, string(ch)))
|
||||||
i++
|
i++
|
||||||
default:
|
default:
|
||||||
out.WriteByte(ch)
|
out.WriteByte(ch)
|
||||||
@@ -265,11 +266,11 @@ func highlightHTML(s string) string {
|
|||||||
if i+4 <= n && s[i:i+4] == "<!--" {
|
if i+4 <= n && s[i:i+4] == "<!--" {
|
||||||
end := strings.Index(s[i:], "-->")
|
end := strings.Index(s[i:], "-->")
|
||||||
if end == -1 {
|
if end == -1 {
|
||||||
out.WriteString(Paint(S.Subtle, s[i:]))
|
out.WriteString(Paint(ilovetui.S.Subtle, s[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
end = i + end + 3
|
end = i + end + 3
|
||||||
out.WriteString(Paint(S.Subtle, s[i:end]))
|
out.WriteString(Paint(ilovetui.S.Subtle, s[i:end]))
|
||||||
i = end
|
i = end
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -278,10 +279,10 @@ func highlightHTML(s string) string {
|
|||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Subtle, "<"))
|
out.WriteString(Paint(ilovetui.S.Subtle, "<"))
|
||||||
i++
|
i++
|
||||||
if i < n && (s[i] == '/' || s[i] == '!') {
|
if i < n && (s[i] == '/' || s[i] == '!') {
|
||||||
out.WriteString(Paint(S.Subtle, string(s[i])))
|
out.WriteString(Paint(ilovetui.S.Subtle, string(s[i])))
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
j := i
|
j := i
|
||||||
@@ -289,7 +290,7 @@ func highlightHTML(s string) string {
|
|||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
if j > i {
|
if j > i {
|
||||||
out.WriteString(Paint(S.Primary, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Primary, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
for i < n && s[i] != '>' {
|
for i < n && s[i] != '>' {
|
||||||
@@ -299,10 +300,10 @@ func highlightHTML(s string) string {
|
|||||||
out.WriteByte(ch)
|
out.WriteByte(ch)
|
||||||
i++
|
i++
|
||||||
case ch == '/':
|
case ch == '/':
|
||||||
out.WriteString(Paint(S.Subtle, "/"))
|
out.WriteString(Paint(ilovetui.S.Subtle, "/"))
|
||||||
i++
|
i++
|
||||||
case ch == '=':
|
case ch == '=':
|
||||||
out.WriteString(Paint(S.Subtle, "="))
|
out.WriteString(Paint(ilovetui.S.Subtle, "="))
|
||||||
i++
|
i++
|
||||||
case ch == '"' || ch == '\'':
|
case ch == '"' || ch == '\'':
|
||||||
q := ch
|
q := ch
|
||||||
@@ -313,19 +314,19 @@ func highlightHTML(s string) string {
|
|||||||
if j < n {
|
if j < n {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Success, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Success, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
default:
|
default:
|
||||||
j = i
|
j = i
|
||||||
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
|
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
|
||||||
j++
|
j++
|
||||||
}
|
}
|
||||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||||
i = j
|
i = j
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if i < n && s[i] == '>' {
|
if i < n && s[i] == '>' {
|
||||||
out.WriteString(Paint(S.Subtle, ">"))
|
out.WriteString(Paint(ilovetui.S.Subtle, ">"))
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-77
@@ -1,106 +1,48 @@
|
|||||||
package style
|
package style
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Styles struct {
|
type Styles struct {
|
||||||
Primary color.Color
|
|
||||||
Success color.Color
|
|
||||||
Error color.Color
|
|
||||||
Warning color.Color
|
|
||||||
SubtleBg color.Color
|
|
||||||
Selection color.Color
|
|
||||||
Text color.Color
|
|
||||||
MutedFg color.Color
|
|
||||||
Subtle color.Color
|
|
||||||
|
|
||||||
Bold lipgloss.Style
|
|
||||||
Faint lipgloss.Style
|
|
||||||
|
|
||||||
Panel lipgloss.Style
|
|
||||||
PanelFocused lipgloss.Style
|
|
||||||
PanelEditing lipgloss.Style
|
PanelEditing lipgloss.Style
|
||||||
|
|
||||||
PagerDotActive string
|
methodGet lipgloss.Style
|
||||||
PagerDotInactive string
|
methodPost lipgloss.Style
|
||||||
|
methodPutPatch lipgloss.Style
|
||||||
|
methodDelete lipgloss.Style
|
||||||
|
methodDefault lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
var S *Styles
|
var S *Styles
|
||||||
|
|
||||||
func Init(cfg *config.Config) {
|
func Init() {
|
||||||
c := cfg.TUI.Colors
|
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||||
|
|
||||||
subtleBg := lipgloss.Color("#" + c.Base01) // Lighter Background (status bars)
|
|
||||||
selection := lipgloss.Color("#" + c.Base02) // Selection Background
|
|
||||||
subtle := lipgloss.Color("#" + c.Base03) // Faint text, borders
|
|
||||||
mutedFg := lipgloss.Color("#" + c.Base04) // Muted foreground
|
|
||||||
text := lipgloss.Color("#" + c.Base05) // Default Foreground
|
|
||||||
errCol := lipgloss.Color("#" + c.Base08) // Red: errors
|
|
||||||
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
|
|
||||||
success := lipgloss.Color("#" + c.Base0B) // Green: success
|
|
||||||
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
|
||||||
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
|
||||||
|
|
||||||
S = &Styles{
|
S = &Styles{
|
||||||
Primary: primary,
|
|
||||||
Success: success,
|
|
||||||
Error: errCol,
|
|
||||||
Warning: warning,
|
|
||||||
SubtleBg: subtleBg,
|
|
||||||
Selection: selection,
|
|
||||||
MutedFg: mutedFg,
|
|
||||||
Text: text,
|
|
||||||
Subtle: subtle,
|
|
||||||
|
|
||||||
Bold: lipgloss.NewStyle().Bold(true),
|
|
||||||
Faint: lipgloss.NewStyle().Foreground(subtle).Faint(true),
|
|
||||||
|
|
||||||
Panel: lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(subtle),
|
|
||||||
|
|
||||||
PanelFocused: lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(primary),
|
|
||||||
|
|
||||||
PanelEditing: lipgloss.NewStyle().
|
PanelEditing: lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(purple),
|
BorderForeground(ilovetui.S.Base0E),
|
||||||
|
|
||||||
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
|
methodGet: methodBase.Foreground(ilovetui.S.Success),
|
||||||
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
|
methodPost: methodBase.Foreground(ilovetui.S.Warning),
|
||||||
|
methodPutPatch: methodBase.Foreground(ilovetui.S.Primary),
|
||||||
|
methodDelete: methodBase.Foreground(ilovetui.S.Error),
|
||||||
|
methodDefault: methodBase.Foreground(ilovetui.S.Text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHelp() help.Model {
|
|
||||||
h := help.New()
|
|
||||||
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
|
|
||||||
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
|
|
||||||
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
|
|
||||||
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
|
|
||||||
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
|
|
||||||
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
|
|
||||||
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Styles) Method(method string) lipgloss.Style {
|
func (s *Styles) Method(method string) lipgloss.Style {
|
||||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
|
||||||
switch method {
|
switch method {
|
||||||
case "GET":
|
case "GET":
|
||||||
return base.Foreground(s.Success)
|
return s.methodGet
|
||||||
case "POST":
|
case "POST":
|
||||||
return base.Foreground(s.Warning)
|
return s.methodPost
|
||||||
case "PUT", "PATCH":
|
case "PUT", "PATCH":
|
||||||
return base.Foreground(s.Primary)
|
return s.methodPutPatch
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return base.Foreground(s.Error)
|
return s.methodDelete
|
||||||
default:
|
default:
|
||||||
return base.Foreground(s.Text)
|
return s.methodDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-10
@@ -31,17 +31,14 @@ const tickInterval = 2 * time.Second
|
|||||||
type tickMsg struct{}
|
type tickMsg struct{}
|
||||||
|
|
||||||
func tickCmd() tea.Cmd {
|
func tickCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return tea.Tick(tickInterval, func(time.Time) tea.Msg {
|
||||||
time.Sleep(tickInterval)
|
|
||||||
return tickMsg{}
|
return tickMsg{}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var sidebarEntries = pageRegistry
|
|
||||||
|
|
||||||
var pageShortcuts = func() map[string]page {
|
var pageShortcuts = func() map[string]page {
|
||||||
m := make(map[string]page, len(sidebarEntries))
|
m := make(map[string]page, len(pageRegistry))
|
||||||
for i, e := range sidebarEntries {
|
for i, e := range pageRegistry {
|
||||||
m[strconv.Itoa(i+1)] = e.id
|
m[strconv.Itoa(i+1)] = e.id
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
@@ -55,6 +52,8 @@ type Model struct {
|
|||||||
database *db.DB
|
database *db.DB
|
||||||
logFile *os.File
|
logFile *os.File
|
||||||
pluginManager *plugins.Manager
|
pluginManager *plugins.Manager
|
||||||
|
fatalErr error
|
||||||
|
logFileErr error
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -94,13 +93,30 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
|
||||||
}
|
}
|
||||||
|
|
||||||
if d, err := db.Open(path); err == nil {
|
d, err := db.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
m.fatalErr = err
|
||||||
|
return m
|
||||||
|
}
|
||||||
m.database = d
|
m.database = d
|
||||||
broker.SetDB(d)
|
broker.SetDB(d)
|
||||||
m.history.SetDB(d)
|
m.history.SetDB(d)
|
||||||
m.replay.SetDB(d)
|
m.replay.SetDB(d)
|
||||||
m.findingsPage.SetDB(d)
|
m.findingsPage.SetDB(d)
|
||||||
mgr.SetDB(d)
|
mgr.SetDB(d)
|
||||||
|
|
||||||
|
pf, err := plugins.OpenPluginsFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugins file: %v", err)
|
||||||
|
}
|
||||||
|
mgr.SetPluginsFile(pf)
|
||||||
|
|
||||||
|
if len(cfg.Plugins) > 0 {
|
||||||
|
defaults := make(map[string]plugins.GlobalDefault, len(cfg.Plugins))
|
||||||
|
for id, gp := range cfg.Plugins {
|
||||||
|
defaults[id] = plugins.GlobalDefault{Enable: gp.Enable, Config: gp.Config}
|
||||||
|
}
|
||||||
|
mgr.SetGlobalDefaults(defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
|
||||||
@@ -113,14 +129,18 @@ func New(broker *intercept.Broker, name, path string) Model {
|
|||||||
m.logFile = lf
|
m.logFile = lf
|
||||||
log.SetOutput(lf)
|
log.SetOutput(lf)
|
||||||
logrus.SetOutput(lf)
|
logrus.SetOutput(lf)
|
||||||
|
} else {
|
||||||
|
m.logFileErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) FatalErr() error { return m.fatalErr }
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
mgr := m.pluginManager
|
mgr := m.pluginManager
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
intercept.WaitForRequest(m.broker),
|
intercept.WaitForRequest(m.broker),
|
||||||
intercept.WaitForResponse(m.broker),
|
intercept.WaitForResponse(m.broker),
|
||||||
tickCmd(),
|
tickCmd(),
|
||||||
@@ -129,5 +149,16 @@ func (m Model) Init() tea.Cmd {
|
|||||||
plugins.WaitForQuit(mgr),
|
plugins.WaitForQuit(mgr),
|
||||||
findingsUI.RefreshCmd(m.database),
|
findingsUI.RefreshCmd(m.database),
|
||||||
func() tea.Msg { mgr.RunOnStart(); return nil },
|
func() tea.Msg { mgr.RunOnStart(); return nil },
|
||||||
)
|
}
|
||||||
|
if m.logFileErr != nil {
|
||||||
|
err := m.logFileErr
|
||||||
|
cmds = append(cmds, func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Warning",
|
||||||
|
Body: "Could not open log file: " + err.Error(),
|
||||||
|
Kind: notificationsUI.KindWarning,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type pageEntry struct {
|
|||||||
isEditing func(m *Model) bool
|
isEditing func(m *Model) bool
|
||||||
// resize propagates a new (w, h) to the page model.
|
// resize propagates a new (w, h) to the page model.
|
||||||
resize func(m *Model, w, h int)
|
resize func(m *Model, w, h int)
|
||||||
|
// hasUpdate reports whether the page has unseen updates.
|
||||||
|
hasUpdate func(m *Model) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var pageRegistry = []pageEntry{
|
var pageRegistry = []pageEntry{
|
||||||
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
|
|||||||
},
|
},
|
||||||
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
||||||
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageHistory,
|
id: pageHistory,
|
||||||
@@ -115,6 +118,7 @@ var pageRegistry = []pageEntry{
|
|||||||
return cmd
|
return cmd
|
||||||
},
|
},
|
||||||
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
|
||||||
|
hasUpdate: func(m *Model) bool { return m.findingsPage.HasUnread() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: pageDocs,
|
id: pageDocs,
|
||||||
@@ -126,6 +130,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) },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-11
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sidebarState string
|
type sidebarState string
|
||||||
@@ -42,7 +42,6 @@ func (m *Model) renderSidebar() string {
|
|||||||
if m.sidebarState == sidebarHidden {
|
if m.sidebarState == sidebarHidden {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
s := style.S
|
|
||||||
// content width inside bordered panel
|
// content width inside bordered panel
|
||||||
inner := m.getSidebarWidth() - 2
|
inner := m.getSidebarWidth() - 2
|
||||||
|
|
||||||
@@ -50,21 +49,26 @@ func (m *Model) renderSidebar() string {
|
|||||||
if m.sidebarState == sidebarCollapsed {
|
if m.sidebarState == sidebarCollapsed {
|
||||||
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(ilovetui.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(ilovetui.S.Primary).Bold(true)
|
||||||
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
|
badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||||
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
|
textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
textNormal := lipgloss.NewStyle().Foreground(s.Text)
|
textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||||
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
||||||
|
|
||||||
var items strings.Builder
|
var items strings.Builder
|
||||||
for i, entry := range sidebarEntries {
|
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||||
|
|
||||||
|
for i, entry := range pageRegistry {
|
||||||
selected := entry.id == m.page
|
selected := entry.id == m.page
|
||||||
badgeStyle, textStyle := badgeNormal, textNormal
|
badgeStyle, textStyle := badgeNormal, textNormal
|
||||||
if selected {
|
if selected {
|
||||||
badgeStyle, textStyle = badgeSelected, textSelected
|
badgeStyle, textStyle = badgeSelected, textSelected
|
||||||
|
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
|
||||||
|
badgeStyle = badgeUnread
|
||||||
}
|
}
|
||||||
icon := ""
|
icon := ""
|
||||||
if entry.icon != nil {
|
if entry.icon != nil {
|
||||||
@@ -75,14 +79,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().Foreground(s.Subtle).Render(divider),
|
lipgloss.NewStyle().Width(inner).Foreground(ilovetui.S.Subtle).Padding(0, 1).Render(name),
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
|
lipgloss.NewStyle().Foreground(ilovetui.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 ilovetui.S.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-35
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
|
||||||
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
|
||||||
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) {
|
||||||
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case intercept.RequestArrivedMsg:
|
case intercept.RequestArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
m.intercept = updated.(interceptUI.Model)
|
m.intercept = updated.(interceptUI.Model)
|
||||||
|
if m.page == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
||||||
case intercept.ResponseArrivedMsg:
|
case intercept.ResponseArrivedMsg:
|
||||||
updated, cmd := m.intercept.Update(msg)
|
updated, cmd := m.intercept.Update(msg)
|
||||||
@@ -104,6 +107,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case proxyPkg.ErrMsg:
|
case proxyPkg.ErrMsg:
|
||||||
if msg.Err != nil {
|
if msg.Err != nil {
|
||||||
log.Printf("proxy error: %v", msg.Err)
|
log.Printf("proxy error: %v", msg.Err)
|
||||||
|
m.fatalErr = msg.Err
|
||||||
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -119,6 +124,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case findingsUI.FindingsLoadedMsg:
|
case findingsUI.FindingsLoadedMsg:
|
||||||
updated, cmd := m.findingsPage.Update(msg)
|
updated, cmd := m.findingsPage.Update(msg)
|
||||||
m.findingsPage = updated.(findingsUI.Model)
|
m.findingsPage = updated.(findingsUI.Model)
|
||||||
|
if m.page == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case replayUI.SendToReplayMsg:
|
case replayUI.SendToReplayMsg:
|
||||||
@@ -154,66 +162,76 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
// ctrl+c always quits, even when a textarea is focused.
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
m.pluginManager.RunOnQuit()
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
|
||||||
m.pluginManager.RunOnQuit()
|
m.pluginManager.RunOnQuit()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
if key.Matches(msg, keys.Keys.Global.OpenLogs) {
|
||||||
editor := os.Getenv("EDITOR")
|
|
||||||
if editor == "" {
|
|
||||||
editor = "vi"
|
|
||||||
}
|
|
||||||
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
|
||||||
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
|
return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.activeIsEditing() {
|
if !m.activeIsEditing() {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, keys.Keys.Global.CopyAs):
|
case key.Matches(msg, keys.Keys.Global.CopyAs):
|
||||||
if m.page == pageDiff {
|
|
||||||
if raw := m.diff.CurrentRaw(); raw != "" {
|
|
||||||
m.copyAs.SetSize(m.width, m.height)
|
|
||||||
m.copyAs.Open(copyasUI.OpenMsg{
|
|
||||||
RawRequest: raw,
|
|
||||||
Scheme: "https",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if m.page == pageIntercept {
|
|
||||||
if raw := m.intercept.CurrentRaw(); raw != "" {
|
|
||||||
m.copyAs.SetSize(m.width, m.height)
|
|
||||||
m.copyAs.Open(copyasUI.OpenMsg{
|
|
||||||
RawRequest: raw,
|
|
||||||
Scheme: m.intercept.CurrentScheme(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Copy):
|
|
||||||
var raw, scheme string
|
var raw, scheme string
|
||||||
|
var responseFocused bool
|
||||||
switch m.page {
|
switch m.page {
|
||||||
case pageIntercept:
|
case pageIntercept:
|
||||||
raw = m.intercept.CurrentRaw()
|
raw = m.intercept.CurrentRaw()
|
||||||
scheme = m.intercept.CurrentScheme()
|
scheme = m.intercept.CurrentScheme()
|
||||||
case pageDiff:
|
responseFocused = m.intercept.IsResponseFocused()
|
||||||
raw = m.diff.CurrentRaw()
|
|
||||||
scheme = "https"
|
|
||||||
case pageHistory:
|
case pageHistory:
|
||||||
raw = m.history.CurrentRaw()
|
raw = m.history.CurrentRaw()
|
||||||
scheme = m.history.CurrentScheme()
|
scheme = m.history.CurrentScheme()
|
||||||
|
responseFocused = m.history.IsResponseFocused()
|
||||||
case pageReplay:
|
case pageReplay:
|
||||||
raw = m.replay.CurrentRaw()
|
raw = m.replay.CurrentRaw()
|
||||||
scheme = m.replay.CurrentScheme()
|
scheme = m.replay.CurrentScheme()
|
||||||
|
responseFocused = m.replay.IsResponseFocused()
|
||||||
|
}
|
||||||
|
if raw != "" && !responseFocused {
|
||||||
|
m.copyAs.SetSize(m.width, m.height)
|
||||||
|
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.Copy):
|
||||||
|
if m.page == pageFindings {
|
||||||
|
if md := m.findingsPage.CurrentMarkdown(); md != "" {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(md),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Finding copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var raw, scheme string
|
||||||
|
var responseFocused bool
|
||||||
|
switch m.page {
|
||||||
|
case pageIntercept:
|
||||||
|
raw = m.intercept.CurrentRaw()
|
||||||
|
scheme = m.intercept.CurrentScheme()
|
||||||
|
responseFocused = m.intercept.IsResponseFocused()
|
||||||
|
case pageHistory:
|
||||||
|
raw = m.history.CurrentRaw()
|
||||||
|
scheme = m.history.CurrentScheme()
|
||||||
|
responseFocused = m.history.IsResponseFocused()
|
||||||
|
case pageReplay:
|
||||||
|
raw = m.replay.CurrentRaw()
|
||||||
|
scheme = m.replay.CurrentScheme()
|
||||||
|
responseFocused = m.replay.IsResponseFocused()
|
||||||
}
|
}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
m.copy.SetSize(m.width, m.height)
|
m.copy.SetSize(m.width, m.height)
|
||||||
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
|
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused})
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -229,8 +247,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.history.RefreshCmd()
|
return m, m.history.RefreshCmd()
|
||||||
}
|
}
|
||||||
if p == pageFindings {
|
if p == pageFindings {
|
||||||
|
m.findingsPage.ClearUnread()
|
||||||
return m, findingsUI.RefreshCmd(m.database)
|
return m, findingsUI.RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
if p == pageIntercept {
|
||||||
|
m.intercept.ClearUnread()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -3,28 +3,31 @@ package app
|
|||||||
import (
|
import (
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (m Model) newView(content string) tea.View {
|
||||||
|
v := tea.NewView(content)
|
||||||
|
v.AltScreen = true
|
||||||
|
v.WindowTitle = "Spilltea: " + m.projectName
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
v := tea.NewView("")
|
return m.newView("")
|
||||||
v.AltScreen = true
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normal := m.renderNormal()
|
normal := m.renderNormal()
|
||||||
|
|
||||||
if m.copyAs.IsOpen() {
|
if m.copyAs.IsOpen() {
|
||||||
v := tea.NewView(m.copyAs.View(normal))
|
v := m.newView(m.copyAs.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.copy.IsOpen() {
|
if m.copy.IsOpen() {
|
||||||
v := tea.NewView(m.copy.View(normal))
|
v := m.newView(m.copy.View(normal))
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,7 @@ func (m Model) View() tea.View {
|
|||||||
rendered = m.notifications.View(normal)
|
rendered = m.notifications.View(normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := tea.NewView(rendered)
|
v := m.newView(rendered)
|
||||||
v.AltScreen = true
|
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -52,5 +54,5 @@ func (m *Model) renderActivePage() string {
|
|||||||
return e.render(m)
|
return e.render(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return style.S.Faint.Render("Work in progress")
|
return ilovetui.S.Faint.Render("Work in progress")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,23 @@
|
|||||||
package copy
|
package copy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const popupInnerW = 40
|
const (
|
||||||
|
popupW = 55
|
||||||
func writeClipboard(text string) {
|
popupH = 20
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
)
|
||||||
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
Scheme string
|
Scheme string
|
||||||
|
ShowURL bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type copyItem struct {
|
type copyItem struct {
|
||||||
@@ -51,22 +47,20 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
delegate := list.NewDefaultDelegate()
|
delegate := list.NewDefaultDelegate()
|
||||||
delegate.SetSpacing(0)
|
delegate.SetSpacing(0)
|
||||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(1)
|
Foreground(ilovetui.S.Muted).PaddingLeft(1)
|
||||||
|
|
||||||
l := list.New(allItems, delegate, popupInnerW, 8)
|
l := list.New(allItems, delegate, popupW, 8)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
@@ -87,19 +81,38 @@ func (m *Model) Open(msg OpenMsg) {
|
|||||||
m.rawRequest = msg.RawRequest
|
m.rawRequest = msg.RawRequest
|
||||||
m.scheme = msg.Scheme
|
m.scheme = msg.Scheme
|
||||||
m.open = true
|
m.open = true
|
||||||
|
items := allItems
|
||||||
|
if !msg.ShowURL {
|
||||||
|
filtered := make([]list.Item, 0, len(allItems))
|
||||||
|
for _, it := range allItems {
|
||||||
|
if it.(copyItem).id != "url" {
|
||||||
|
filtered = append(filtered, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = filtered
|
||||||
|
}
|
||||||
|
m.list.SetItems(items)
|
||||||
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 := 12
|
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
|
||||||
}
|
}
|
||||||
@@ -110,7 +123,7 @@ func (m Model) popupHeight() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) listHeight() int {
|
func (m Model) listHeight() int {
|
||||||
return style.PanelContentH(m.popupHeight()) - 1
|
return ilovetui.ContentHeight(m.popupHeight()) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) extract(id string) string {
|
func (m Model) extract(id string) string {
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||||
switch {
|
switch {
|
||||||
case kp.String() == "enter":
|
case kp.String() == "enter":
|
||||||
if item, ok := m.list.SelectedItem().(copyItem); ok {
|
|
||||||
writeClipboard(m.extract(item.id))
|
|
||||||
}
|
|
||||||
m.open = false
|
m.open = false
|
||||||
|
if item, ok := m.list.SelectedItem().(copyItem); ok {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(m.extract(item.id)),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Request copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||||
if m.list.SettingFilter() {
|
if m.list.SettingFilter() {
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package copy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
|
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Model) View(background string) string {
|
func (m *Model) View(background string) string {
|
||||||
s := style.S
|
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||||
|
|
||||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
|
||||||
Render(" enter: copy • /: filter • esc: cancel")
|
Render(" enter: copy • /: filter • esc: cancel")
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
@@ -19,10 +17,10 @@ func (m *Model) View(background string) string {
|
|||||||
|
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary)
|
BorderForeground(ilovetui.S.Primary)
|
||||||
|
|
||||||
popupH := m.popupHeight()
|
popupH := m.popupHeight()
|
||||||
popup := style.RenderWithTitle(border, "Copy", inner, popupInnerW+2, popupH)
|
popup := ilovetui.RenderWithTitle(border, "Copy", inner, m.popupInnerWidth()+2, popupH)
|
||||||
|
|
||||||
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
|
return copyasUI.OverlayCenter(background, popup, m.width, m.height)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package copyas
|
package copyas
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type header struct{ key, value string }
|
type header struct{ key, value string }
|
||||||
@@ -17,41 +21,17 @@ type parsedRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseRaw(raw, scheme string) parsedRequest {
|
func parseRaw(raw, scheme string) parsedRequest {
|
||||||
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
r := util.ParseRawRequest(raw)
|
||||||
pr := parsedRequest{scheme: scheme}
|
pr := parsedRequest{
|
||||||
if len(lines) == 0 {
|
method: r.Method,
|
||||||
return pr
|
path: r.Path,
|
||||||
|
host: r.Host,
|
||||||
|
scheme: scheme,
|
||||||
}
|
}
|
||||||
|
for _, h := range r.Headers {
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
pr.headers = append(pr.headers, header{h.Key, h.Value})
|
||||||
if len(parts) >= 1 {
|
|
||||||
pr.method = strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
pr.path = strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
i := 1
|
|
||||||
for i < len(lines) {
|
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
|
||||||
k := strings.TrimSpace(kv[0])
|
|
||||||
v := strings.TrimSpace(kv[1])
|
|
||||||
pr.headers = append(pr.headers, header{k, v})
|
|
||||||
if strings.EqualFold(k, "host") {
|
|
||||||
pr.host = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < len(lines) {
|
|
||||||
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
|
|
||||||
}
|
}
|
||||||
|
pr.body = r.Body
|
||||||
return pr
|
return pr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +58,31 @@ func formatAs(id, raw, scheme string) string {
|
|||||||
return toFFUF(pr)
|
return toFFUF(pr)
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return toMarkdown(pr)
|
return toMarkdown(pr)
|
||||||
|
case "har":
|
||||||
|
return toHAR(pr)
|
||||||
|
case "httpie":
|
||||||
|
return toHTTPie(pr)
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHTTPie(pr parsedRequest) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
method := strings.ToUpper(pr.method)
|
||||||
|
fmt.Fprintf(&sb, "http %s '%s'", method, pr.fullURL())
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
if strings.EqualFold(h.key, "content-length") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, " \\\n '%s:%s'", h.key, h.value)
|
||||||
|
}
|
||||||
|
if pr.body != "" {
|
||||||
|
// Pass body via stdin hint
|
||||||
|
fmt.Fprintf(&sb, " \\\n <<< %q", pr.body)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func toMarkdown(pr parsedRequest) string {
|
func toMarkdown(pr parsedRequest) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
|
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
|
||||||
@@ -200,3 +201,104 @@ func toFFUF(pr parsedRequest) string {
|
|||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHAR(pr parsedRequest) string {
|
||||||
|
type harNameValue struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
type harPostData struct {
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
type harRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTTPVersion string `json:"httpVersion"`
|
||||||
|
Headers []harNameValue `json:"headers"`
|
||||||
|
QueryString []harNameValue `json:"queryString"`
|
||||||
|
Cookies []harNameValue `json:"cookies"`
|
||||||
|
HeadersSize int `json:"headersSize"`
|
||||||
|
BodySize int `json:"bodySize"`
|
||||||
|
PostData *harPostData `json:"postData,omitempty"`
|
||||||
|
}
|
||||||
|
type harEntry struct {
|
||||||
|
StartedDateTime string `json:"startedDateTime"`
|
||||||
|
Time int `json:"time"`
|
||||||
|
Request harRequest `json:"request"`
|
||||||
|
Cache struct{} `json:"cache"`
|
||||||
|
Timings struct {
|
||||||
|
Send int `json:"send"`
|
||||||
|
Wait int `json:"wait"`
|
||||||
|
Receive int `json:"receive"`
|
||||||
|
} `json:"timings"`
|
||||||
|
}
|
||||||
|
type harLog struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Entries []harEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
type harRoot struct {
|
||||||
|
Log harLog `json:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make([]harNameValue, 0, len(pr.headers))
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
headers = append(headers, harNameValue{h.key, h.value})
|
||||||
|
}
|
||||||
|
|
||||||
|
var qs []harNameValue
|
||||||
|
if idx := strings.Index(pr.path, "?"); idx != -1 {
|
||||||
|
vals, err := url.ParseQuery(pr.path[idx+1:])
|
||||||
|
if err == nil {
|
||||||
|
for k, vs := range vals {
|
||||||
|
for _, v := range vs {
|
||||||
|
qs = append(qs, harNameValue{k, v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if qs == nil {
|
||||||
|
qs = []harNameValue{}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := harRequest{
|
||||||
|
Method: pr.method,
|
||||||
|
URL: pr.fullURL(),
|
||||||
|
HTTPVersion: "HTTP/1.1",
|
||||||
|
Headers: headers,
|
||||||
|
QueryString: qs,
|
||||||
|
Cookies: []harNameValue{},
|
||||||
|
HeadersSize: -1,
|
||||||
|
BodySize: len(pr.body),
|
||||||
|
}
|
||||||
|
if pr.body != "" {
|
||||||
|
mimeType := "application/octet-stream"
|
||||||
|
for _, h := range pr.headers {
|
||||||
|
if strings.EqualFold(h.key, "content-type") {
|
||||||
|
mimeType = h.value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.PostData = &harPostData{MimeType: mimeType, Text: pr.body}
|
||||||
|
}
|
||||||
|
|
||||||
|
root := harRoot{Log: harLog{
|
||||||
|
Version: "1.2",
|
||||||
|
Entries: []harEntry{{
|
||||||
|
StartedDateTime: "1970-01-01T00:00:00.000Z",
|
||||||
|
Time: -1,
|
||||||
|
Request: req,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
root.Log.Creator.Name = "spilltea"
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(root, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
package copyas
|
package copyas
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/list"
|
"charm.land/bubbles/v2/list"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const popupInnerW = 46
|
const (
|
||||||
|
popupW = 61
|
||||||
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
|
popupH = 20
|
||||||
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
|
)
|
||||||
func writeClipboard(text string) {
|
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(text))
|
|
||||||
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenMsg struct {
|
type OpenMsg struct {
|
||||||
RawRequest string
|
RawRequest string
|
||||||
@@ -42,6 +34,8 @@ var allFormats = []list.Item{
|
|||||||
formatItem{"go", "Go", "net/http package"},
|
formatItem{"go", "Go", "net/http package"},
|
||||||
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
|
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
|
||||||
formatItem{"markdown", "Markdown", "formatted for documentation"},
|
formatItem{"markdown", "Markdown", "formatted for documentation"},
|
||||||
|
formatItem{"har", "HAR", "HTTP Archive (JSON)"},
|
||||||
|
formatItem{"httpie", "HTTPie", "HTTPie command line client"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -54,22 +48,20 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
delegate := list.NewDefaultDelegate()
|
delegate := list.NewDefaultDelegate()
|
||||||
delegate.SetSpacing(0)
|
delegate.SetSpacing(0)
|
||||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(1)
|
Foreground(ilovetui.S.Muted).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)
|
||||||
@@ -92,17 +84,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
|
||||||
}
|
}
|
||||||
@@ -114,5 +114,5 @@ func (m Model) popupHeight() int {
|
|||||||
|
|
||||||
// listHeight = panel content area - hint line (1)
|
// listHeight = panel content area - hint line (1)
|
||||||
func (m Model) listHeight() int {
|
func (m Model) listHeight() int {
|
||||||
return style.PanelContentH(m.popupHeight()) - 1
|
return ilovetui.ContentHeight(m.popupHeight()) - 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
if kp, ok := msg.(tea.KeyPressMsg); ok {
|
||||||
switch {
|
switch {
|
||||||
case kp.String() == "enter":
|
case kp.String() == "enter":
|
||||||
if item, ok := m.list.SelectedItem().(formatItem); ok {
|
|
||||||
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
|
|
||||||
}
|
|
||||||
m.open = false
|
m.open = false
|
||||||
|
if item, ok := m.list.SelectedItem().(formatItem); ok {
|
||||||
|
return m, tea.Batch(
|
||||||
|
tea.SetClipboard(formatAs(item.id, m.rawRequest, m.scheme)),
|
||||||
|
func() tea.Msg {
|
||||||
|
return notificationsUI.NotificationMsg{
|
||||||
|
Title: "Copied",
|
||||||
|
Body: "Request copied to clipboard",
|
||||||
|
Kind: notificationsUI.KindSuccess,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case key.Matches(kp, keys.Keys.Global.Escape):
|
case key.Matches(kp, keys.Keys.Global.Escape):
|
||||||
if m.list.SettingFilter() {
|
if m.list.SettingFilter() {
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m *Model) View(background string) string {
|
func (m *Model) View(background string) string {
|
||||||
s := style.S
|
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||||
|
|
||||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
|
||||||
Render(" enter: copy • /: filter • esc: cancel")
|
Render(" enter: copy • /: filter • esc: cancel")
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
@@ -21,17 +19,15 @@ func (m *Model) View(background string) string {
|
|||||||
|
|
||||||
border := lipgloss.NewStyle().
|
border := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary)
|
BorderForeground(ilovetui.S.Primary)
|
||||||
|
|
||||||
popupH := m.popupHeight()
|
popupH := m.popupHeight()
|
||||||
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+2, popupH)
|
popup := ilovetui.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
|
|
||||||
|
|
||||||
stripped := ansi.Strip(bg)
|
stripped := ansi.Strip(bg)
|
||||||
rawLines := strings.Split(stripped, "\n")
|
rawLines := strings.Split(stripped, "\n")
|
||||||
bgRunes := make([][]rune, h)
|
bgRunes := make([][]rune, h)
|
||||||
@@ -67,7 +63,7 @@ func OverlayCenter(bg, popup string, w, h int) string {
|
|||||||
startX = 0
|
startX = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
|
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true)
|
||||||
|
|
||||||
result := make([]string, h)
|
result := make([]string, h)
|
||||||
for y := 0; y < h; y++ {
|
for y := 0; y < h; y++ {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +76,6 @@ func (m Model) View(background string) string {
|
|||||||
return background
|
return background
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
|
||||||
const popupW = 34
|
const popupW = 34
|
||||||
|
|
||||||
var popups []string
|
var popups []string
|
||||||
@@ -89,17 +88,17 @@ func (m Model) View(background string) string {
|
|||||||
var accent color.Color
|
var accent color.Color
|
||||||
switch n.kind {
|
switch n.kind {
|
||||||
case KindSuccess:
|
case KindSuccess:
|
||||||
accent = s.Success
|
accent = ilovetui.S.Success
|
||||||
case KindWarning:
|
case KindWarning:
|
||||||
accent = s.Warning
|
accent = ilovetui.S.Warning
|
||||||
case KindError:
|
case KindError:
|
||||||
accent = s.Error
|
accent = ilovetui.S.Error
|
||||||
default:
|
default:
|
||||||
accent = s.Primary
|
accent = ilovetui.S.Primary
|
||||||
}
|
}
|
||||||
|
|
||||||
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
|
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
|
||||||
bodyStr := lipgloss.NewStyle().Foreground(s.Text).Width(popupW).Render(n.body)
|
bodyStr := lipgloss.NewStyle().Foreground(ilovetui.S.Text).Width(popupW).Render(n.body)
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
|
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
|
||||||
box := lipgloss.NewStyle().
|
box := lipgloss.NewStyle().
|
||||||
|
|||||||
+166
-20
@@ -8,10 +8,161 @@ import (
|
|||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isWordChar reports whether c belongs to a "word" token (letter, digit, underscore).
|
||||||
|
func isWordChar(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits s into runs of word characters and individual non-word bytes.
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
var out []string
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if isWordChar(s[i]) {
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isWordChar(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, s[i:j])
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
out = append(out, s[i:i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordDiff computes a token-level diff between leftLine and rightLine and
|
||||||
|
// returns the two rendered strings with changed tokens highlighted.
|
||||||
|
func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
|
||||||
|
lToks := tokenize(leftLine)
|
||||||
|
rToks := tokenize(rightLine)
|
||||||
|
|
||||||
|
n, m := len(lToks), len(rToks)
|
||||||
|
dp := make([][]int, n+1)
|
||||||
|
for i := range dp {
|
||||||
|
dp[i] = make([]int, m+1)
|
||||||
|
}
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
for j := 1; j <= m; j++ {
|
||||||
|
if lToks[i-1] == rToks[j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j-1] + 1
|
||||||
|
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j]
|
||||||
|
} else {
|
||||||
|
dp[i][j] = dp[i][j-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type segment struct {
|
||||||
|
kind int // 0=same, 1=left-only, 2=right-only
|
||||||
|
tok string
|
||||||
|
}
|
||||||
|
segs := make([]segment, 0, n+m)
|
||||||
|
i, j := n, m
|
||||||
|
for i > 0 || j > 0 {
|
||||||
|
switch {
|
||||||
|
case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]:
|
||||||
|
segs = append(segs, segment{0, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||||
|
segs = append(segs, segment{2, rToks[j-1]})
|
||||||
|
j--
|
||||||
|
default:
|
||||||
|
segs = append(segs, segment{1, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 {
|
||||||
|
segs[lo], segs[hi] = segs[hi], segs[lo]
|
||||||
|
}
|
||||||
|
|
||||||
|
boldErr := lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true)
|
||||||
|
boldOk := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true)
|
||||||
|
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||||
|
|
||||||
|
var lb, rb strings.Builder
|
||||||
|
for _, seg := range segs {
|
||||||
|
switch seg.kind {
|
||||||
|
case 0:
|
||||||
|
lb.WriteString(dim.Render(seg.tok))
|
||||||
|
rb.WriteString(dim.Render(seg.tok))
|
||||||
|
case 1:
|
||||||
|
lb.WriteString(boldErr.Render(seg.tok))
|
||||||
|
case 2:
|
||||||
|
rb.WriteString(boldOk.Render(seg.tok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lb.String(), rb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairAndHighlight collapses adjacent removed/added blocks onto the same rows
|
||||||
|
// (eliminating the interleaved padding lines) and applies word-level diff
|
||||||
|
// highlighting to each paired line. Unpaired excess removals/additions keep
|
||||||
|
// their original single-sided padding row.
|
||||||
|
func pairAndHighlight(left, right []diffLine) ([]diffLine, []diffLine) {
|
||||||
|
newLeft := make([]diffLine, 0, len(left))
|
||||||
|
newRight := make([]diffLine, 0, len(right))
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(left) {
|
||||||
|
if left[i].kind != lineRemoved {
|
||||||
|
newLeft = append(newLeft, left[i])
|
||||||
|
newRight = append(newRight, right[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineRemoved {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
rEnd := i
|
||||||
|
|
||||||
|
aStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineAdded {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
aEnd := i
|
||||||
|
|
||||||
|
nRemoved := rEnd - rStart
|
||||||
|
nAdded := aEnd - aStart
|
||||||
|
pairs := nRemoved
|
||||||
|
if nAdded < pairs {
|
||||||
|
pairs = nAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := 0; k < pairs; k++ {
|
||||||
|
lLine := left[rStart+k]
|
||||||
|
rLine := right[aStart+k]
|
||||||
|
lLine.text, rLine.text = wordDiff(lLine.plainText, rLine.plainText)
|
||||||
|
newLeft = append(newLeft, lLine)
|
||||||
|
newRight = append(newRight, rLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := pairs; k < nRemoved; k++ {
|
||||||
|
newLeft = append(newLeft, left[rStart+k])
|
||||||
|
newRight = append(newRight, diffLine{kind: lineRemoved})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := pairs; k < nAdded; k++ {
|
||||||
|
newLeft = append(newLeft, diffLine{kind: lineAdded})
|
||||||
|
newRight = append(newRight, right[aStart+k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newLeft, newRight
|
||||||
|
}
|
||||||
|
|
||||||
type slot struct {
|
type slot struct {
|
||||||
label string
|
label string
|
||||||
raw string
|
raw string
|
||||||
@@ -38,7 +189,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type diffLine struct {
|
type diffLine struct {
|
||||||
text string
|
text string // displayed text (highlighted, possibly word-diff decorated)
|
||||||
|
plainText string // plain text for word-diff pairing (empty for padding lines)
|
||||||
kind lineKind
|
kind lineKind
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +212,9 @@ type Model struct {
|
|||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
return Model{
|
||||||
leftViewport: style.NewViewport(),
|
leftViewport: ilovetui.NewViewport(),
|
||||||
rightViewport: style.NewViewport(),
|
rightViewport: ilovetui.NewViewport(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +255,7 @@ func (m *Model) recalcSizes() {
|
|||||||
rightInner = 0
|
rightInner = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
viewportH := style.PanelContentH(panelH)
|
viewportH := ilovetui.ContentHeight(panelH)
|
||||||
|
|
||||||
m.leftViewport.SetWidth(leftInner)
|
m.leftViewport.SetWidth(leftInner)
|
||||||
m.leftViewport.SetHeight(viewportH)
|
m.leftViewport.SetHeight(viewportH)
|
||||||
@@ -126,6 +278,7 @@ func (m *Model) computeDiff() {
|
|||||||
leftHL := hlLines(leftNorm)
|
leftHL := hlLines(leftNorm)
|
||||||
rightHL := hlLines(rightNorm)
|
rightHL := hlLines(rightNorm)
|
||||||
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
||||||
|
m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normRaw(s string) string {
|
func normRaw(s string) string {
|
||||||
@@ -143,13 +296,11 @@ func hlLines(raw string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshViewports() {
|
func (m *Model) refreshViewports() {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
if m.left.raw == "" {
|
if m.left.raw == "" {
|
||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" <(^_^)>\nsend two entries here to compare"),
|
ilovetui.S.Faint.Render(util.EmptyState(m.leftViewport.Width(), "<(^_^)>", "send two entries here to compare")),
|
||||||
)
|
)
|
||||||
m.leftViewport.SetContent(placeholder)
|
m.leftViewport.SetContent(placeholder)
|
||||||
m.rightViewport.SetContent("")
|
m.rightViewport.SetContent("")
|
||||||
@@ -161,7 +312,7 @@ func (m *Model) refreshViewports() {
|
|||||||
placeholder := lipgloss.Place(
|
placeholder := lipgloss.Place(
|
||||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" (・3・)\nwaiting for second entry…"),
|
ilovetui.S.Faint.Render(util.EmptyState(m.rightViewport.Width(), "(・3・)", "waiting for second entry…")),
|
||||||
)
|
)
|
||||||
m.rightViewport.SetContent(placeholder)
|
m.rightViewport.SetContent(placeholder)
|
||||||
return
|
return
|
||||||
@@ -227,10 +378,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
|||||||
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), plainText: b[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), plainText: a[i-1], kind: lineRemoved})
|
||||||
right = append(right, diffLine{kind: lineRemoved})
|
right = append(right, diffLine{kind: lineRemoved})
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
@@ -243,14 +394,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 +402,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}
|
||||||
|
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-19
@@ -5,8 +5,10 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -25,8 +27,6 @@ func (m Model) View() tea.View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderPanels(panelH int) string {
|
func (m *Model) renderPanels(panelH int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
leftW := m.width / 2
|
leftW := m.width / 2
|
||||||
rightW := m.width - leftW
|
rightW := m.width - leftW
|
||||||
|
|
||||||
@@ -38,21 +38,27 @@ func (m *Model) renderPanels(panelH int) string {
|
|||||||
if m.right.label != "" {
|
if m.right.label != "" {
|
||||||
rightTitle = icons.I.Diff + "Second: " + m.right.label
|
rightTitle = icons.I.Diff + "Second: " + m.right.label
|
||||||
}
|
}
|
||||||
|
if maxW := leftW - 4; maxW > 0 {
|
||||||
leftBorder := s.Panel
|
leftTitle = ansi.Truncate(leftTitle, maxW, "…")
|
||||||
rightBorder := s.Panel
|
}
|
||||||
switch m.focus {
|
if maxW := rightW - 4; maxW > 0 {
|
||||||
case bothSlots:
|
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
|
||||||
leftBorder = s.PanelFocused
|
|
||||||
rightBorder = s.PanelFocused
|
|
||||||
case leftSlot:
|
|
||||||
leftBorder = s.PanelFocused
|
|
||||||
case rightSlot:
|
|
||||||
rightBorder = s.PanelFocused
|
|
||||||
}
|
}
|
||||||
|
|
||||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
leftBorder := ilovetui.S.Panel
|
||||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
rightBorder := ilovetui.S.Panel
|
||||||
|
switch m.focus {
|
||||||
|
case bothSlots:
|
||||||
|
leftBorder = ilovetui.S.PanelFocused
|
||||||
|
rightBorder = ilovetui.S.PanelFocused
|
||||||
|
case leftSlot:
|
||||||
|
leftBorder = ilovetui.S.PanelFocused
|
||||||
|
case rightSlot:
|
||||||
|
rightBorder = ilovetui.S.PanelFocused
|
||||||
|
}
|
||||||
|
|
||||||
|
left := ilovetui.RenderWithTitle(leftBorder, leftTitle, ilovetui.ViewportView(&m.leftViewport), leftW, panelH)
|
||||||
|
right := ilovetui.RenderWithTitle(rightBorder, rightTitle, ilovetui.ViewportView(&m.rightViewport), rightW, panelH)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||||
}
|
}
|
||||||
@@ -62,12 +68,11 @@ func (m *Model) renderStatusBar() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderLeftLines(lines []diffLine) string {
|
func renderLeftLines(lines []diffLine) string {
|
||||||
s := style.S
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
switch l.kind {
|
switch l.kind {
|
||||||
case lineRemoved:
|
case lineRemoved:
|
||||||
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
|
sb.WriteString(style.Paint(ilovetui.S.Error, "- ") + l.text + "\n")
|
||||||
case lineAdded:
|
case lineAdded:
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
default:
|
default:
|
||||||
@@ -78,12 +83,11 @@ func renderLeftLines(lines []diffLine) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderRightLines(lines []diffLine) string {
|
func renderRightLines(lines []diffLine) string {
|
||||||
s := style.S
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
switch l.kind {
|
switch l.kind {
|
||||||
case lineAdded:
|
case lineAdded:
|
||||||
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
|
sb.WriteString(style.Paint(ilovetui.S.Success, "+ ") + l.text + "\n")
|
||||||
case lineRemoved:
|
case lineRemoved:
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
default:
|
default:
|
||||||
|
|||||||
+253
-1
@@ -1,16 +1,25 @@
|
|||||||
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"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,19 +27,262 @@ var contentMarkdown = strings.Join([]string{
|
|||||||
readDoc("main.md"),
|
readDoc("main.md"),
|
||||||
readDoc("proxy.md"),
|
readDoc("proxy.md"),
|
||||||
readDoc("certificate.md"),
|
readDoc("certificate.md"),
|
||||||
|
readDoc("legal-disclaimer.md"),
|
||||||
|
readDoc("basics.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(ilovetui.S.Primary)
|
||||||
|
ti.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
viewport: viewport.New(),
|
viewport: viewport.New(),
|
||||||
|
help: ilovetui.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(ilovetui.S.SubtleBg))
|
||||||
|
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.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)
|
||||||
|
}
|
||||||
|
|||||||
+41
-25
@@ -4,47 +4,63 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
util.HandleMouseWheel(msg, &e.viewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
|
if e.searching {
|
||||||
switch {
|
switch {
|
||||||
|
case key.Matches(msg, g.Escape):
|
||||||
|
e.searching = false
|
||||||
|
e.searchInput.Blur()
|
||||||
|
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):
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := e.viewport.Height() / 2
|
util.ScrollViewport(&e.viewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() - step)
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := e.viewport.Height() / 2
|
util.ScrollViewport(&e.viewport, 1)
|
||||||
if step < 1 {
|
case key.Matches(msg, g.Help):
|
||||||
step = 1
|
e.help.ShowAll = !e.help.ShowAll
|
||||||
}
|
e.SetSize(e.width, e.height)
|
||||||
e.viewport.SetYOffset(e.viewport.YOffset() + step)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,25 +2,43 @@ package docs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
_ "embed"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func windowStyle() lipgloss.Style {
|
func windowStyle() lipgloss.Style {
|
||||||
return lipgloss.NewStyle().
|
return lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(style.S.Subtle).
|
BorderForeground(ilovetui.S.Subtle).
|
||||||
Padding(0, 0)
|
Padding(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(ilovetui.S.Muted).
|
||||||
|
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() {
|
||||||
@@ -43,10 +61,15 @@ func (m *Model) renderMarkdown() {
|
|||||||
|
|
||||||
width := m.viewport.Width() - 2
|
width := m.viewport.Width() - 2
|
||||||
renderer, _ := glamour.NewTermRenderer(
|
renderer, _ := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,37 +11,53 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
database *db.DB
|
database *db.DB
|
||||||
findings []db.Finding
|
findings []db.Finding
|
||||||
cursor int
|
cursor int
|
||||||
|
hasUnread bool
|
||||||
|
knownCount int
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
bodyViewport viewport.Model
|
bodyViewport viewport.Model
|
||||||
pager paginator.Model
|
pager paginator.Model
|
||||||
help help.Model
|
help help.Model
|
||||||
|
|
||||||
|
renderer *glamour.TermRenderer
|
||||||
|
rendererWidth int
|
||||||
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
bodyViewport: style.NewViewport(),
|
bodyViewport: ilovetui.NewViewport(),
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
|
||||||
|
|
||||||
|
func (m *Model) CurrentMarkdown() string {
|
||||||
|
if len(m.findings) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f := m.findings[m.cursor]
|
||||||
|
return "# " + f.Title + "\n\n" + f.Description
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) SetDB(d *db.DB) {
|
func (m *Model) SetDB(d *db.DB) {
|
||||||
m.database = d
|
m.database = d
|
||||||
}
|
}
|
||||||
@@ -59,9 +75,9 @@ func (m *Model) recalcSizes() {
|
|||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
inner := m.width - 2
|
inner := m.width - 2
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||||
if listVH < 0 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -72,16 +88,21 @@ func (m *Model) recalcSizes() {
|
|||||||
m.pager.PerPage = 1
|
m.pager.PerPage = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
|
if m.rendererWidth != inner {
|
||||||
|
m.renderer = nil
|
||||||
|
m.rendererWidth = 0
|
||||||
|
}
|
||||||
|
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -104,19 +125,29 @@ type FindingsLoadedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshBody() {
|
func (m *Model) refreshBody() {
|
||||||
|
m.refreshBodyScroll(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshBodyKeepScroll() {
|
||||||
|
m.refreshBodyScroll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshBodyScroll(reset bool) {
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
m.bodyViewport.SetContent("")
|
m.bodyViewport.SetContent("")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f := m.findings[m.cursor]
|
f := m.findings[m.cursor]
|
||||||
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
|
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
|
||||||
m.bodyViewport.SetContent(rendered)
|
m.bodyViewport.SetContent(rendered)
|
||||||
|
if reset {
|
||||||
m.bodyViewport.GotoTop()
|
m.bodyViewport.GotoTop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdown(src string, width int) string {
|
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||||
if src == "" {
|
if src == "" {
|
||||||
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
|
return ilovetui.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no description"))
|
||||||
}
|
}
|
||||||
tmpl, err := template.New("").Parse(src)
|
tmpl, err := template.New("").Parse(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -129,28 +160,39 @@ func renderMarkdown(src string, width int) string {
|
|||||||
if width < 10 {
|
if width < 10 {
|
||||||
width = 80
|
width = 80
|
||||||
}
|
}
|
||||||
|
// Rebuild renderer if width changed or not yet built.
|
||||||
|
if m.renderer == nil || m.rendererWidth != width {
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
m.renderer = r
|
||||||
|
m.rendererWidth = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.renderer == nil {
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
out, err := r.Render(buf.String())
|
out, err := m.renderer.Render(buf.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return buf.String()
|
return buf.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.Flag, f.Dismiss, f.DismissAll, g.Copy, 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, g.Copy}
|
||||||
|
all := append(keys.Keys.Findings.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"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) {
|
||||||
@@ -15,22 +16,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
log.Printf("findings load error: %v", msg.Err)
|
log.Printf("findings load error: %v", msg.Err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
var prevID int64
|
||||||
|
if len(m.findings) > 0 && m.cursor < len(m.findings) {
|
||||||
|
prevID = m.findings[m.cursor].ID
|
||||||
|
}
|
||||||
m.findings = msg.Findings
|
m.findings = msg.Findings
|
||||||
|
if len(m.findings) > m.knownCount {
|
||||||
|
m.hasUnread = true
|
||||||
|
}
|
||||||
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()
|
||||||
|
var newID int64
|
||||||
|
if len(m.findings) > 0 && m.cursor < len(m.findings) {
|
||||||
|
newID = m.findings[m.cursor].ID
|
||||||
|
}
|
||||||
|
if newID != prevID {
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
} else {
|
||||||
|
m.refreshBodyKeepScroll()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -56,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, f.Flag):
|
||||||
|
if len(m.findings) > 0 && m.database != nil {
|
||||||
|
if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil {
|
||||||
|
log.Printf("findings: toggle flag: %v", err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, RefreshCmd(m.database)
|
||||||
|
}
|
||||||
case key.Matches(msg, f.Dismiss):
|
case key.Matches(msg, f.Dismiss):
|
||||||
if len(m.findings) > 0 && m.database != nil {
|
if len(m.findings) > 0 && m.database != nil {
|
||||||
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
|
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
|
||||||
@@ -64,18 +88,45 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, RefreshCmd(m.database)
|
return m, RefreshCmd(m.database)
|
||||||
}
|
}
|
||||||
|
case key.Matches(msg, f.DismissAll):
|
||||||
|
if m.database != nil {
|
||||||
|
if err := m.database.DismissAllFindings(); err != nil {
|
||||||
|
log.Printf("findings: dismiss all: %v", err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, RefreshCmd(m.database)
|
||||||
|
}
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
case key.Matches(msg, g.GotoTop):
|
||||||
step = 1
|
m.cursor = 0
|
||||||
}
|
m.pager.Page = 0
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.findings))
|
||||||
|
m.pager.Page = util.CursorGotoBottom(m.pager.TotalPages)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, false)
|
||||||
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.findings), m.pager.PerPage, true)
|
||||||
|
m.pager.Page = m.cursor / m.pager.PerPage
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.Help):
|
||||||
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
|
m.recalcSizes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
return tea.NewView("Loading...")
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -27,41 +28,35 @@ 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
|
var dots string
|
||||||
dots := s.Faint.Render(m.pager.View())
|
if len(m.findings) > 0 {
|
||||||
|
dots = ilovetui.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.Findings+"Findings", inner, w, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, icons.I.Findings+"Findings", inner, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderBodyPanel(h int) string {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
title := "Description"
|
title := "Description"
|
||||||
if len(m.findings) > 0 {
|
if len(m.findings) > 0 {
|
||||||
title = m.findings[m.cursor].Title
|
title = m.findings[m.cursor].Title
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
|
||||||
if len(m.findings) == 0 {
|
if len(m.findings) == 0 {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(" (҂◡_◡) ᕤ\nno findings"),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(҂◡_◡) ᕤ", "no findings")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, f := range m.findings[start:end] {
|
for i, f := range m.findings[start:end] {
|
||||||
@@ -71,39 +66,50 @@ func (m *Model) renderList() string {
|
|||||||
sevStyle := style.SeverityStyle(f.Severity)
|
sevStyle := style.SeverityStyle(f.Severity)
|
||||||
sevLabel := sevStyle.Width(8).Render(f.Severity)
|
sevLabel := sevStyle.Width(8).Render(f.Severity)
|
||||||
ts := f.CreatedAt.Format("15:04:05")
|
ts := f.CreatedAt.Format("15:04:05")
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
|
const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1
|
||||||
titleW := w - fixedW
|
titleW := w - fixedW
|
||||||
if titleW < 0 {
|
if titleW < 0 {
|
||||||
titleW = 0
|
titleW = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
|
pluginStr := ilovetui.S.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||||
|
flagStr := " "
|
||||||
|
if f.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
|
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
|
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Foreground(s.Subtle).Width(10).Render(ts),
|
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(titleW).Render(f.Title),
|
bg.Bold(true).Width(titleW).Render(f.Title),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if f.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
|
flagSt.Width(2).Render(flagStr),
|
||||||
sevLabel,
|
sevLabel,
|
||||||
" ",
|
" ",
|
||||||
pluginStr,
|
pluginStr,
|
||||||
" ",
|
" ",
|
||||||
s.Faint.Width(10).Render(ts),
|
ilovetui.S.Faint.Width(10).Render(ts),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(f.Title),
|
ilovetui.S.Bold.Render(f.Title),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("%s\n", line))
|
sb.WriteString(fmt.Sprintf("%s\n", line))
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"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"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type panel int
|
type panel int
|
||||||
@@ -43,10 +44,10 @@ func New() Model {
|
|||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Prompt = ""
|
ti.Prompt = ""
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
bodyViewport: style.NewViewport(),
|
bodyViewport: ilovetui.NewViewport(),
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
searchInput: ti,
|
searchInput: ti,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +60,22 @@ func (m Model) CurrentRaw() string {
|
|||||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.entries[m.cursor].ResponseRaw
|
||||||
|
}
|
||||||
return m.entries[m.cursor].RequestRaw
|
return m.entries[m.cursor].RequestRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) CurrentScheme() string { return "https" }
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.focusedPanel == panelResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) CurrentScheme() string {
|
||||||
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
return util.InferScheme(m.entries[m.cursor].Host)
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -111,14 +124,14 @@ func (m *Model) recalcSizes() {
|
|||||||
// 2 (padding) + 2 (prefix char + space)
|
// 2 (padding) + 2 (prefix char + space)
|
||||||
m.searchInput.SetWidth(m.width - 4)
|
m.searchInput.SetWidth(m.width - 4)
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
inner := m.width - 2
|
inner := m.width - 2
|
||||||
if inner < 0 {
|
if inner < 0 {
|
||||||
inner = 0
|
inner = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||||
if listVH < 0 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -129,7 +142,7 @@ func (m *Model) recalcSizes() {
|
|||||||
m.pager.PerPage = 1
|
m.pager.PerPage = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
m.bodyViewport.SetWidth(inner)
|
m.bodyViewport.SetWidth(inner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
@@ -145,14 +158,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
|
||||||
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
g := keys.Keys.Global
|
||||||
all = append(all, keys.Keys.Global.Bindings()...)
|
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.Flag, h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
|
||||||
|
all = append(all, pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-53
@@ -2,10 +2,13 @@ package history
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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"
|
||||||
@@ -23,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
|
|||||||
if database == nil {
|
if database == nil {
|
||||||
return EntriesLoadedMsg{}
|
return EntriesLoadedMsg{}
|
||||||
}
|
}
|
||||||
entries, _ := database.ListEntries()
|
entries, err := database.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("history: load entries: %v", err)
|
||||||
|
}
|
||||||
return EntriesLoadedMsg{Entries: entries}
|
return EntriesLoadedMsg{Entries: entries}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,31 +41,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
|
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
prevCursor := m.cursor
|
// Remember the selected entry's ID so we can re-anchor after the list is
|
||||||
|
// reloaded (new entries are prepended; a pure index-based cursor would
|
||||||
|
// silently jump to a different entry).
|
||||||
|
var selectedID int64
|
||||||
|
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||||
|
selectedID = m.entries[m.cursor].ID
|
||||||
|
}
|
||||||
m.entries = msg.Entries
|
m.entries = msg.Entries
|
||||||
|
entryChanged := true
|
||||||
|
if selectedID != 0 {
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if e.ID == selectedID {
|
||||||
|
m.cursor = i
|
||||||
|
entryChanged = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if m.cursor >= len(m.entries) {
|
if m.cursor >= len(m.entries) {
|
||||||
m.cursor = len(m.entries) - 1
|
m.cursor = len(m.entries) - 1
|
||||||
|
entryChanged = true
|
||||||
}
|
}
|
||||||
if m.cursor < 0 {
|
if m.cursor < 0 {
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
entryChanged = true
|
||||||
}
|
}
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
if m.cursor != prevCursor {
|
if entryChanged {
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
case SearchResultMsg:
|
case SearchResultMsg:
|
||||||
|
var selectedID int64
|
||||||
|
if m.cursor >= 0 && m.cursor < len(m.entries) {
|
||||||
|
selectedID = m.entries[m.cursor].ID
|
||||||
|
}
|
||||||
m.entries = msg.Entries
|
m.entries = msg.Entries
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
|
if selectedID != 0 {
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if e.ID == selectedID {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
m.searchErr = ""
|
m.searchErr = ""
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
if selectedID == 0 {
|
||||||
m.bodyViewport.SetYOffset(0)
|
m.bodyViewport.SetYOffset(0)
|
||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
}
|
||||||
if m.searchKind == searchKindSQL {
|
if m.searchKind == searchKindSQL {
|
||||||
m.acceptSearch()
|
m.acceptSearch()
|
||||||
}
|
}
|
||||||
@@ -74,31 +112,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.bodyViewport.SetXOffset(0)
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelLeft:
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
h := keys.Keys.History
|
h := keys.Keys.History
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
|
|
||||||
if m.searchKind != searchKindOff && !m.searchAccepted {
|
if m.searchKind != searchKindOff && !m.searchAccepted {
|
||||||
// Actively typing: only search navigation + accept/cancel.
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, g.Escape):
|
case key.Matches(msg, g.Escape):
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
@@ -109,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.acceptSearch()
|
m.acceptSearch()
|
||||||
|
|
||||||
case key.Matches(msg, g.Up):
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
m.bodyViewport.SetYOffset(0)
|
|
||||||
m.bodyViewport.SetXOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Down):
|
|
||||||
if m.cursor < len(m.entries)-1 {
|
|
||||||
m.cursor++
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
m.bodyViewport.SetYOffset(0)
|
|
||||||
m.bodyViewport.SetXOffset(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
@@ -229,11 +231,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, h.Flag):
|
||||||
|
if len(m.entries) > 0 && m.database != nil {
|
||||||
|
if err := m.database.ToggleFlag(m.entries[m.cursor].ID); err != nil {
|
||||||
|
log.Printf("history: toggle flag: %v", err)
|
||||||
|
}
|
||||||
|
return m, m.RefreshCmd()
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, h.DeleteEntry):
|
case key.Matches(msg, h.DeleteEntry):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
id := m.entries[m.cursor].ID
|
id := m.entries[m.cursor].ID
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteEntry(id)
|
if err := m.database.DeleteEntry(id); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, LoadEntriesCmd(m.database)
|
return m, LoadEntriesCmd(m.database)
|
||||||
}
|
}
|
||||||
@@ -241,28 +253,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, h.DeleteAll):
|
case key.Matches(msg, h.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
|
hasUnflagged := false
|
||||||
for _, e := range m.entries {
|
for _, e := range m.entries {
|
||||||
m.database.DeleteEntry(e.ID)
|
if !e.Flagged {
|
||||||
|
hasUnflagged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range m.entries {
|
||||||
|
if hasUnflagged && e.Flagged {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.database.DeleteEntry(e.ID); err != nil {
|
||||||
|
log.Printf("history: delete entry: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.database.DeleteAllEntries()
|
if err := m.database.DeleteAllExceptFlagged(); err != nil {
|
||||||
|
log.Printf("history: delete all unflagged: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, m.clearSearch()
|
return m, m.clearSearch()
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Left):
|
case key.Matches(msg, g.Left):
|
||||||
m.bodyViewport.ScrollLeft(6)
|
m.bodyViewport.ScrollLeft(6)
|
||||||
@@ -270,6 +288,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case key.Matches(msg, g.Right):
|
case key.Matches(msg, g.Right):
|
||||||
m.bodyViewport.ScrollRight(6)
|
m.bodyViewport.ScrollRight(6)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.pager.Page = 0
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
m.bodyViewport.SetYOffset(0)
|
||||||
|
m.bodyViewport.SetXOffset(0)
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Help):
|
case key.Matches(msg, keys.Keys.Global.Help):
|
||||||
m.help.ShowAll = !m.help.ShowAll
|
m.help.ShowAll = !m.help.ShowAll
|
||||||
m.recalcSizes()
|
m.recalcSizes()
|
||||||
@@ -281,9 +328,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 +351,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, ilovetui.S.Faint.Render(util.EmptyState(w, "(˘・_・˘)", "no response stored"))))
|
||||||
|
return
|
||||||
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-34
@@ -6,9 +6,11 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -16,7 +18,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
return tea.NewView("Loading...")
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -27,89 +29,82 @@ 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
|
var dots string
|
||||||
dots := s.Faint.Render(m.pager.View())
|
if len(m.entries) > 0 {
|
||||||
|
dots = ilovetui.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.History+"History", inner, w, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.PanelFocused, icons.I.History+"History", inner, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderBodyPanel(h int) string {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
title := icons.I.Request + "Request"
|
title := icons.I.Request + "Request"
|
||||||
if m.focusedPanel == panelResponse {
|
if m.focusedPanel == panelResponse {
|
||||||
title = icons.I.Response + "Response"
|
title = icons.I.Response + "Response"
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
s := style.S
|
|
||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
switch m.searchKind {
|
switch m.searchKind {
|
||||||
case searchKindFulltext:
|
case searchKindFulltext:
|
||||||
filterKey := keys.Keys.History.Filter.Help().Key
|
filterKey := keys.Keys.History.Filter.Help().Key
|
||||||
if m.searchAccepted {
|
if m.searchAccepted {
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.searchInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
|
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
|
||||||
}
|
}
|
||||||
return pad.Render(s.Faint.Render(filterKey) + " " + m.searchInput.View())
|
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.searchInput.View())
|
||||||
case searchKindSQL:
|
case searchKindSQL:
|
||||||
sqlKey := keys.Keys.History.SqlQuery.Help().Key
|
sqlKey := keys.Keys.History.SqlQuery.Help().Key
|
||||||
if m.searchAccepted {
|
if m.searchAccepted {
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
filterLine := pad.Render(accent.Render(sqlKey) + " " + ilovetui.S.Bold.Render(m.searchInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
|
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(historyKeyMap{width: m.width})))
|
||||||
}
|
}
|
||||||
return pad.Render(s.Faint.Render(sqlKey) + " " + m.searchInput.View())
|
return pad.Render(ilovetui.S.Faint.Render(sqlKey) + " " + m.searchInput.View())
|
||||||
default:
|
default:
|
||||||
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
|
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
s := style.S
|
|
||||||
if m.searchErr != "" {
|
if m.searchErr != "" {
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
lipgloss.NewStyle().Foreground(s.Error).Render(m.searchErr),
|
lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render(m.searchErr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
msg := " (⌐■_■)\nno history yet"
|
msg := util.EmptyState(m.listViewport.Width(), "(⌐■_■)", "no history yet")
|
||||||
if m.searchKind != searchKindOff {
|
if m.searchKind != searchKindOff {
|
||||||
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results"
|
msg = util.EmptyState(m.listViewport.Width(), "ʕノ•ᴥ•ʔノ ︵ ┻━┻", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(msg),
|
ilovetui.S.Faint.Render(msg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
|
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
|
|
||||||
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
||||||
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1
|
const fixedW = 2 + 2 + 7 + 1 + 3 + 1 + 10 + 1
|
||||||
hostPathW := w - fixedW
|
hostPathW := w - fixedW
|
||||||
if hostPathW < 0 {
|
if hostPathW < 0 {
|
||||||
hostPathW = 0
|
hostPathW = 0
|
||||||
@@ -117,31 +112,42 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
ts := e.Timestamp.Format("15:04:05")
|
ts := e.Timestamp.Format("15:04:05")
|
||||||
statusSt := style.StatusStyle(e.StatusCode, 3)
|
statusSt := style.StatusStyle(e.StatusCode, 3)
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
|
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Foreground(s.Subtle).Width(10).Render(ts),
|
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
s.Method(e.Method).Render(e.Method),
|
flagSt.Width(2).Render(flagStr),
|
||||||
|
style.S.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
" ",
|
" ",
|
||||||
s.Faint.Width(10).Render(ts),
|
ilovetui.S.Faint.Width(10).Render(ts),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(e.Host),
|
ilovetui.S.Bold.Render(e.Host),
|
||||||
s.Faint.Render(e.Path),
|
ilovetui.S.Faint.Render(e.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
+19
-20
@@ -14,10 +14,10 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"charm.land/bubbles/v2/textinput"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/db"
|
"github.com/anotherhadi/spilltea/internal/db"
|
||||||
"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/ui/components/teapot"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,19 +88,18 @@ type homeDelegate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newHomeDelegate() homeDelegate {
|
func newHomeDelegate() homeDelegate {
|
||||||
s := style.S
|
|
||||||
leftBorder := lipgloss.Border{Left: "│"}
|
leftBorder := lipgloss.Border{Left: "│"}
|
||||||
return homeDelegate{
|
return homeDelegate{
|
||||||
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
|
normalTitle: lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(4),
|
||||||
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
|
normalDesc: lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true).PaddingLeft(4),
|
||||||
selectedTitle: lipgloss.NewStyle().
|
selectedTitle: lipgloss.NewStyle().
|
||||||
Border(leftBorder, false, false, false, true).
|
Border(leftBorder, false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.Primary).Bold(true).PaddingLeft(3),
|
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(3),
|
||||||
selectedDesc: lipgloss.NewStyle().
|
selectedDesc: lipgloss.NewStyle().
|
||||||
Border(leftBorder, false, false, false, true).
|
Border(leftBorder, false, false, false, true).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Foreground(s.MutedFg).PaddingLeft(3),
|
Foreground(ilovetui.S.Muted).PaddingLeft(3),
|
||||||
filterMatch: lipgloss.NewStyle().Underline(true),
|
filterMatch: lipgloss.NewStyle().Underline(true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +141,11 @@ type Project struct {
|
|||||||
ModTime time.Time
|
ModTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectSelectedMsg is emitted when the user picks a project from the home screen.
|
||||||
|
type ProjectSelectedMsg struct {
|
||||||
|
Project *Project
|
||||||
|
}
|
||||||
|
|
||||||
type inputMode int
|
type inputMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -161,16 +165,11 @@ type Model struct {
|
|||||||
list list.Model
|
list list.Model
|
||||||
projectDir string
|
projectDir string
|
||||||
nameInput textinput.Model
|
nameInput textinput.Model
|
||||||
selected *Project
|
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
teapotFrame int
|
teapotFrame int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected returns the project chosen by the user, or nil if the program was
|
|
||||||
// quit without making a selection.
|
|
||||||
func (m Model) Selected() *Project { return m.selected }
|
|
||||||
|
|
||||||
func New(projectDir string) Model {
|
func New(projectDir string) Model {
|
||||||
projects := loadProjects(projectDir)
|
projects := loadProjects(projectDir)
|
||||||
|
|
||||||
@@ -304,14 +303,13 @@ func buildItems(projects []Project) []list.Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderHelpLine() string {
|
func (m Model) renderHelpLine() string {
|
||||||
s := style.S
|
|
||||||
k := keys.Keys.Home
|
k := keys.Keys.Home
|
||||||
fs := m.list.FilterState()
|
fs := m.list.FilterState()
|
||||||
|
|
||||||
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
|
kStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Inline(true)
|
||||||
dStyle := s.Faint.Inline(true)
|
dStyle := ilovetui.S.Faint.Inline(true)
|
||||||
|
|
||||||
sep := s.Faint.Inline(true).Render(" • ")
|
sep := ilovetui.S.Faint.Inline(true).Render(" • ")
|
||||||
item := func(keyStr, desc string) string {
|
item := func(keyStr, desc string) string {
|
||||||
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
|
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
|
||||||
}
|
}
|
||||||
@@ -320,19 +318,20 @@ func (m Model) renderHelpLine() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
if fs == list.Filtering {
|
if fs == list.Filtering {
|
||||||
parts = append(parts, item("enter", "apply filter"))
|
parts = append(parts, item("enter", "apply filter"))
|
||||||
parts = append(parts, item("esc", "cancel"))
|
parts = append(parts, item(escKey, "cancel"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, item("↑/↓", "navigate"))
|
parts = append(parts, item("↑/↓", "navigate"))
|
||||||
if fs == list.FilterApplied {
|
if fs == list.FilterApplied {
|
||||||
parts = append(parts, item("esc", "clear filter"))
|
parts = append(parts, item(escKey, "clear filter"))
|
||||||
} else {
|
} else {
|
||||||
parts = append(parts, binding(k.Filter))
|
parts = append(parts, binding(k.Filter))
|
||||||
}
|
}
|
||||||
parts = append(parts, binding(k.Open))
|
parts = append(parts, binding(k.Open))
|
||||||
parts = append(parts, binding(k.Delete))
|
parts = append(parts, binding(k.Delete))
|
||||||
parts = append(parts, item("q", "quit"))
|
parts = append(parts, item(keys.Keys.Global.Quit.Help().Key, "quit"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(parts, sep)
|
return strings.Join(parts, sep)
|
||||||
|
|||||||
+22
-15
@@ -3,6 +3,7 @@ package home
|
|||||||
import (
|
import (
|
||||||
crypto "crypto/rand"
|
crypto "crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -72,15 +73,16 @@ func (m Model) handleSelection() (tea.Model, tea.Cmd) {
|
|||||||
return m, m.nameInput.Focus()
|
return m, m.nameInput.Focus()
|
||||||
case kindTemp:
|
case kindTemp:
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create temp dir: %v", err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
p := &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
default:
|
default:
|
||||||
m.selected = &Project{Name: item.name, Path: item.path}
|
p := &Project{Name: item.name, Path: item.path}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +92,9 @@ func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(item.path) // parent dir of data.db
|
dir := filepath.Dir(item.path) // parent dir of data.db
|
||||||
os.RemoveAll(dir)
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
log.Printf("home: remove project dir: %v", err)
|
||||||
|
}
|
||||||
idx := m.list.GlobalIndex()
|
idx := m.list.GlobalIndex()
|
||||||
m.list.RemoveItem(idx)
|
m.list.RemoveItem(idx)
|
||||||
if idx > 0 && idx >= len(m.list.Items()) {
|
if idx > 0 && idx >= len(m.list.Items()) {
|
||||||
@@ -113,12 +117,13 @@ func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = modeSelect
|
m.mode = modeSelect
|
||||||
m.nameInput.Blur()
|
m.nameInput.Blur()
|
||||||
dir := filepath.Join(m.projectDir, name)
|
dir := filepath.Join(m.projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
log.Printf("home: create project dir %q: %v", name, err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
p := &Project{Name: name, Path: filepath.Join(dir, "data.db")}
|
||||||
return m, tea.Quit
|
return m, func() tea.Msg { return ProjectSelectedMsg{Project: p} }
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.nameInput, cmd = m.nameInput.Update(msg)
|
m.nameInput, cmd = m.nameInput.Update(msg)
|
||||||
@@ -138,23 +143,23 @@ func sanitizeName(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsValidProjectName(s string) bool {
|
func IsValidProjectName(s string) bool {
|
||||||
if s == "tmp" {
|
if s == "tmp" || s == "temp" || s == "temporary" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return s != "" && s == sanitizeName(s)
|
return s != "" && s == sanitizeName(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenProject(projectDir, name string) (*Project, error) {
|
func OpenProject(projectDir, name string) (*Project, error) {
|
||||||
if name == "tmp" {
|
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||||
dir := tempDir()
|
dir := tempDir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Join(projectDir, name)
|
dir := filepath.Join(projectDir, name)
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
initProjectFiles(dir)
|
initProjectFiles(dir)
|
||||||
@@ -171,9 +176,11 @@ func initProjectFiles(dir string) {
|
|||||||
for _, name := range []string{"data.db", "logs.log"} {
|
for _, name := range []string{"data.db", "logs.log"} {
|
||||||
p := filepath.Join(dir, name)
|
p := filepath.Join(dir, name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
f, err := os.Create(p)
|
f, err := os.Create(p) // #nosec G304 -- p is filepath.Join(dir, hardcoded_name), no traversal possible
|
||||||
if err == nil {
|
if err == nil {
|
||||||
f.Close()
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
log.Printf("home: close project file: %v", cerr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,29 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||||
)
|
)
|
||||||
|
|
||||||
const inputPanelMaxW = 44
|
const inputPanelMaxW = 44
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
s := style.S
|
|
||||||
iw := m.innerW()
|
iw := m.innerW()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if m.height > teapotMinH {
|
if m.height > teapotMinH {
|
||||||
frames := teapot.TeapotFrames()
|
frames := teapot.TeapotFrames()
|
||||||
frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame])
|
frame := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render(frames[m.teapotFrame])
|
||||||
sb.WriteString(center(iw, frame))
|
sb.WriteString(center(iw, frame))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA")))
|
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Render("SPILLTEA")))
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(center(iw, s.Faint.Render("choose a project to get started")))
|
sb.WriteString(center(iw, ilovetui.S.Faint.Render("choose a project to get started")))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
if m.mode == modeNaming {
|
if m.mode == modeNaming {
|
||||||
@@ -46,11 +46,11 @@ func (m Model) View() tea.View {
|
|||||||
v := tea.NewView(content)
|
v := tea.NewView(content)
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
|
v.WindowTitle = "Spilltea"
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderNamingPanel() string {
|
func (m Model) renderNamingPanel() string {
|
||||||
s := style.S
|
|
||||||
iw := m.innerW()
|
iw := m.innerW()
|
||||||
|
|
||||||
panelW := inputPanelMaxW
|
panelW := inputPanelMaxW
|
||||||
@@ -63,15 +63,16 @@ func (m Model) renderNamingPanel() string {
|
|||||||
innerW := inputPanelInnerW(iw)
|
innerW := inputPanelInnerW(iw)
|
||||||
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
|
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
|
||||||
|
|
||||||
label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name")
|
label := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).Render("Project name")
|
||||||
panel := lipgloss.NewStyle().
|
panel := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(s.Primary).
|
BorderForeground(ilovetui.S.Primary).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
Width(panelW).
|
Width(panelW).
|
||||||
Render(label + "\n" + inputLine)
|
Render(label + "\n" + inputLine)
|
||||||
|
|
||||||
hint := s.Faint.Render("[enter] confirm [esc] cancel")
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
|
hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel")
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(center(iw, panel))
|
sb.WriteString(center(iw, panel))
|
||||||
|
|||||||
@@ -4,113 +4,45 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func formatRawRequest(req *intercept.PendingRequest) string {
|
|
||||||
r := req.Flow.Request
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
|
||||||
|
|
||||||
keys := make([]string, 0, len(r.Header))
|
|
||||||
for k := range r.Header {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if len(r.Body) > 0 {
|
|
||||||
sb.Write(r.Body)
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatRawResponse(resp *intercept.PendingResponse) string {
|
|
||||||
r := resp.Flow.Response
|
|
||||||
if r == nil {
|
|
||||||
return "(no response)"
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
proto := resp.Flow.Request.Proto
|
|
||||||
if proto == "" {
|
|
||||||
proto = "HTTP/1.1"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
|
|
||||||
|
|
||||||
keys := make([]string, 0, len(r.Header))
|
|
||||||
for k := range r.Header {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, v := range r.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString("\n")
|
|
||||||
if len(r.Body) > 0 {
|
|
||||||
sb.Write(r.Body)
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRawRequest(content string, req *intercept.PendingRequest) {
|
func parseRawRequest(content string, req *intercept.PendingRequest) {
|
||||||
|
parsed := util.ParseRawRequest(content)
|
||||||
r := req.Flow.Request
|
r := req.Flow.Request
|
||||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
if parsed.Method != "" {
|
||||||
if len(lines) == 0 {
|
r.Method = parsed.Method
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if parsed.Path != "" {
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
if u, err := url.ParseRequestURI(parsed.Path); err == nil {
|
||||||
if len(parts) >= 1 {
|
|
||||||
r.Method = strings.TrimSpace(parts[0])
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
|
|
||||||
r.URL.Path = u.Path
|
r.URL.Path = u.Path
|
||||||
r.URL.RawQuery = u.RawQuery
|
r.URL.RawQuery = u.RawQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(parts) >= 3 {
|
if parsed.Proto != "" {
|
||||||
r.Proto = strings.TrimSpace(parts[2])
|
r.Proto = parsed.Proto
|
||||||
|
}
|
||||||
|
if parsed.Host != "" {
|
||||||
|
r.URL.Host = parsed.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Header = make(http.Header)
|
r.Header = make(http.Header)
|
||||||
i := 1
|
for _, h := range parsed.Headers {
|
||||||
for i < len(lines) {
|
if strings.EqualFold(h.Key, "host") {
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
continue
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
r.Header.Set(h.Key, h.Value)
|
||||||
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
|
|
||||||
}
|
}
|
||||||
i++
|
if parsed.Body != "" {
|
||||||
}
|
r.Body = []byte(parsed.Body)
|
||||||
|
|
||||||
if i < len(lines) {
|
|
||||||
body := strings.Join(lines[i:], "\n")
|
|
||||||
body = strings.TrimRight(body, "\n")
|
|
||||||
if body != "" {
|
|
||||||
r.Body = []byte(body)
|
|
||||||
} else {
|
} else {
|
||||||
r.Body = nil
|
r.Body = nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRawResponse(content string, resp *intercept.PendingResponse) {
|
func parseRawResponse(content string, resp *intercept.PendingResponse) {
|
||||||
@@ -232,20 +164,20 @@ func (m *Model) listHalfWidths() (leftW, rightW int) {
|
|||||||
func (m *Model) recalcSizes() {
|
func (m *Model) recalcSizes() {
|
||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
bodyInner := m.width - 2
|
bodyInner := m.width - 2
|
||||||
if bodyInner < 0 {
|
if bodyInner < 0 {
|
||||||
bodyInner = 0
|
bodyInner = 0
|
||||||
}
|
}
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
|
|
||||||
m.textarea.SetWidth(bodyInner)
|
m.textarea.SetWidth(bodyInner)
|
||||||
m.textarea.SetHeight(bodyVH)
|
m.textarea.SetHeight(bodyVH)
|
||||||
m.bodyViewport.SetWidth(bodyInner)
|
m.bodyViewport.SetWidth(bodyInner)
|
||||||
m.bodyViewport.SetHeight(bodyVH)
|
m.bodyViewport.SetHeight(bodyVH)
|
||||||
|
|
||||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||||
if listVH < 0 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -295,17 +227,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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +275,7 @@ func (m *Model) loadIntoTextarea() {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
m.textarea.SetValue(edited)
|
m.textarea.SetValue(edited)
|
||||||
} else {
|
} else {
|
||||||
m.textarea.SetValue(formatRawResponse(resp))
|
m.textarea.SetValue(intercept.FormatRawResponse(resp.Flow))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
@@ -343,7 +285,7 @@ func (m *Model) loadIntoTextarea() {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
m.textarea.SetValue(edited)
|
m.textarea.SetValue(edited)
|
||||||
} else {
|
} else {
|
||||||
m.textarea.SetValue(formatRawRequest(req))
|
m.textarea.SetValue(intercept.FormatRawRequest(req.Flow))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,7 +302,7 @@ func (m *Model) refreshBody() {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
raw = edited
|
raw = edited
|
||||||
} else {
|
} else {
|
||||||
raw = formatRawResponse(resp)
|
raw = intercept.FormatRawResponse(resp.Flow)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
@@ -371,7 +313,7 @@ func (m *Model) refreshBody() {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
raw = edited
|
raw = edited
|
||||||
} else {
|
} else {
|
||||||
raw = formatRawRequest(req)
|
raw = intercept.FormatRawRequest(req.Flow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
package intercept
|
package intercept
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newHelp() help.Model { return style.NewHelp() }
|
|
||||||
|
|
||||||
type interceptKeyMap struct{ width int }
|
type interceptKeyMap struct{ width int }
|
||||||
|
|
||||||
func iconBinding(b key.Binding, icon string) key.Binding {
|
|
||||||
h := b.Help()
|
|
||||||
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (interceptKeyMap) ShortHelp() []key.Binding {
|
func (interceptKeyMap) ShortHelp() []key.Binding {
|
||||||
ic := keys.Keys.Intercept
|
ic := keys.Keys.Intercept
|
||||||
i := icons.I
|
|
||||||
return []key.Binding{
|
return []key.Binding{
|
||||||
iconBinding(ic.Forward, i.Forward),
|
ic.Forward,
|
||||||
iconBinding(ic.Drop, i.Drop),
|
ic.Drop,
|
||||||
iconBinding(ic.Edit, i.Edit),
|
ic.Edit,
|
||||||
keys.Keys.Global.Help,
|
keys.Keys.Global.Help,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textarea"
|
"charm.land/bubbles/v2/textarea"
|
||||||
"charm.land/bubbles/v2/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type panel int
|
type panel int
|
||||||
@@ -30,6 +30,7 @@ type Model struct {
|
|||||||
|
|
||||||
editing bool
|
editing bool
|
||||||
interceptEnabled bool
|
interceptEnabled bool
|
||||||
|
hasUnread bool
|
||||||
pendingEdits map[*intercept.PendingRequest]string
|
pendingEdits map[*intercept.PendingRequest]string
|
||||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||||
|
|
||||||
@@ -47,14 +48,14 @@ type Model struct {
|
|||||||
|
|
||||||
func New(broker *intercept.Broker) Model {
|
func New(broker *intercept.Broker) Model {
|
||||||
cfg := config.Global
|
cfg := config.Global
|
||||||
ta := style.NewTextarea(false)
|
ta := ilovetui.NewTextarea(false)
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
|
||||||
lv := style.NewViewport()
|
lv := ilovetui.NewViewport()
|
||||||
rv := style.NewViewport()
|
rv := ilovetui.NewViewport()
|
||||||
bv := style.NewViewport()
|
bv := ilovetui.NewViewport()
|
||||||
p := style.NewPaginator()
|
p := ilovetui.NewPaginator()
|
||||||
rp := style.NewPaginator()
|
rp := ilovetui.NewPaginator()
|
||||||
|
|
||||||
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
|
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ func New(broker *intercept.Broker) Model {
|
|||||||
textarea: ta,
|
textarea: ta,
|
||||||
pager: p,
|
pager: p,
|
||||||
responsePager: rp,
|
responsePager: rp,
|
||||||
help: newHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
pendingEdits: make(map[*intercept.PendingRequest]string),
|
pendingEdits: make(map[*intercept.PendingRequest]string),
|
||||||
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
|
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
|
||||||
}
|
}
|
||||||
@@ -76,8 +77,15 @@ func New(broker *intercept.Broker) Model {
|
|||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||||
|
func (m *Model) ClearUnread() { m.hasUnread = false }
|
||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing }
|
||||||
|
|
||||||
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.captureResponse && m.focusedPanel == panelResponses
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) CurrentScheme() string {
|
func (m Model) CurrentScheme() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return "https"
|
return "https"
|
||||||
@@ -98,7 +106,7 @@ func (m Model) CurrentRaw() string {
|
|||||||
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
if edited, ok := m.pendingResponseEdits[resp]; ok {
|
||||||
return edited
|
return edited
|
||||||
}
|
}
|
||||||
return formatRawResponse(resp)
|
return intercept.FormatRawResponse(resp.Flow)
|
||||||
}
|
}
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -107,7 +115,7 @@ func (m Model) CurrentRaw() string {
|
|||||||
if edited, ok := m.pendingEdits[req]; ok {
|
if edited, ok := m.pendingEdits[req]; ok {
|
||||||
return edited
|
return edited
|
||||||
}
|
}
|
||||||
return formatRawRequest(req)
|
return intercept.FormatRawRequest(req.Flow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
func (m *Model) SetSize(w, h int) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
wasEmpty := len(m.queue) == 0
|
wasEmpty := len(m.queue) == 0
|
||||||
m.queue = append(m.queue, msg.Req)
|
m.queue = append(m.queue, msg.Req)
|
||||||
|
m.hasUnread = true
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
@@ -52,24 +53,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
if !m.editing {
|
if !m.editing {
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.bodyViewport)
|
||||||
case tea.MouseWheelUp:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelDown:
|
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
} else {
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
||||||
}
|
|
||||||
case tea.MouseWheelLeft:
|
|
||||||
m.bodyViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
|
||||||
m.bodyViewport.ScrollRight(6)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -127,18 +111,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
||||||
step := m.bodyViewport.Height() / 2
|
util.ScrollViewport(&m.bodyViewport, 1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Left):
|
case key.Matches(msg, keys.Keys.Global.Left):
|
||||||
m.bodyViewport.ScrollLeft(6)
|
m.bodyViewport.ScrollLeft(6)
|
||||||
@@ -146,9 +122,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
case key.Matches(msg, keys.Keys.Global.Right):
|
case key.Matches(msg, keys.Keys.Global.Right):
|
||||||
m.bodyViewport.ScrollRight(6)
|
m.bodyViewport.ScrollRight(6)
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.Quit):
|
|
||||||
return m, tea.Quit
|
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
||||||
if onResponses {
|
if onResponses {
|
||||||
if len(m.responseQueue) > 0 {
|
if len(m.responseQueue) > 0 {
|
||||||
@@ -237,10 +210,10 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
|
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
|
||||||
if !onResponses && len(m.queue) > 0 {
|
if !onResponses && len(m.queue) > 0 {
|
||||||
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor]))
|
return m, util.OpenExternalEditor(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
|
||||||
}
|
}
|
||||||
if onResponses && len(m.responseQueue) > 0 {
|
if onResponses && len(m.responseQueue) > 0 {
|
||||||
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor]))
|
return m, util.OpenExternalEditor(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Global.SendToReplay):
|
case key.Matches(msg, keys.Keys.Global.SendToReplay):
|
||||||
@@ -268,14 +241,52 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
|
|||||||
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = 0
|
||||||
|
} else {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorGotoBottom(len(m.responseQueue))
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.queue))
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, false)
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, false)
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
||||||
|
if onResponses {
|
||||||
|
m.responseCursor = util.CursorMovePage(m.responseCursor, len(m.responseQueue), m.responsePager.PerPage, true)
|
||||||
|
} else {
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.queue), m.pager.PerPage, true)
|
||||||
|
}
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshResponseListViewport()
|
||||||
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, tea.Batch(*cmds...)
|
return m, tea.Batch(*cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
|
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
|
||||||
onResponses := m.captureResponse && m.focusedPanel == panelResponses
|
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, keys.Keys.Global.Escape):
|
case key.Matches(msg, keys.Keys.Global.Escape):
|
||||||
m.saveCurrentEdit()
|
m.saveCurrentEdit()
|
||||||
@@ -283,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
m.refreshBodyViewport()
|
m.refreshBodyViewport()
|
||||||
|
|
||||||
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
|
|
||||||
if onResponses {
|
|
||||||
if len(m.responseQueue) > 0 {
|
|
||||||
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
|
|
||||||
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor]))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(m.queue) > 0 {
|
|
||||||
delete(m.pendingEdits, m.queue[m.cursor])
|
|
||||||
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.textarea, cmd = m.textarea.Update(msg)
|
m.textarea, cmd = m.textarea.Update(msg)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -15,7 +17,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
return tea.NewView("Loading...")
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
var listRow string
|
var listRow string
|
||||||
if m.captureResponse {
|
if m.captureResponse {
|
||||||
@@ -37,15 +39,16 @@ 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
|
|
||||||
|
|
||||||
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
|
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if focused {
|
if focused {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
dots := s.Faint.Render(m.pager.View())
|
var dots string
|
||||||
|
if len(m.queue) > 0 {
|
||||||
|
dots = ilovetui.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),
|
||||||
@@ -53,46 +56,42 @@ func (m *Model) renderListPanel(w, h int) string {
|
|||||||
|
|
||||||
title := icons.I.Request + "Requests"
|
title := icons.I.Request + "Requests"
|
||||||
if !m.interceptEnabled {
|
if !m.interceptEnabled {
|
||||||
title += " " + lipgloss.NewStyle().Foreground(style.S.Error).Render("[intercept off]")
|
title += " " + lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render("[intercept off]")
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(border, title, inner, w, h)
|
return ilovetui.RenderWithTitle(border, title, inner, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderResponseListPanel(w, h int) string {
|
func (m *Model) renderResponseListPanel(w, h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
focused := !m.editing && m.focusedPanel == panelResponses
|
focused := !m.editing && m.focusedPanel == panelResponses
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if focused {
|
if focused {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
dots := s.Faint.Render(m.responsePager.View())
|
dots := ilovetui.S.Faint.Render(m.responsePager.View())
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.responseViewport.View(),
|
m.responseViewport.View(),
|
||||||
lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots),
|
lipgloss.PlaceHorizontal(m.responseViewport.Width(), lipgloss.Center, dots),
|
||||||
)
|
)
|
||||||
|
|
||||||
return style.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h)
|
return ilovetui.RenderWithTitle(border, icons.I.Response+"Responses", inner, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderBodyPanel(h int) string {
|
func (m *Model) renderBodyPanel(h int) string {
|
||||||
s := style.S
|
|
||||||
|
|
||||||
var body string
|
var body string
|
||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
} else {
|
} else {
|
||||||
body = m.bodyViewport.View()
|
body = ilovetui.ViewportView(&m.bodyViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if m.editing {
|
if m.editing {
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
|
|
||||||
title := icons.I.Detail + "Details"
|
title := icons.I.Detail + "Details"
|
||||||
return style.RenderWithTitle(border, title, body, m.width, h)
|
return ilovetui.RenderWithTitle(border, title, body, m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
@@ -101,17 +100,10 @@ func (m *Model) renderStatusBar() string {
|
|||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
if len(m.queue) == 0 {
|
if len(m.queue) == 0 {
|
||||||
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (。◕‿‿◕。)\nwaiting for a request"))
|
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(。◕‿‿◕。)", "waiting for a request")))
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||||
start, end := m.pager.GetSliceBounds(len(m.queue))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, req := range m.queue[start:end] {
|
for i, req := range m.queue[start:end] {
|
||||||
@@ -123,7 +115,7 @@ func (m *Model) renderList() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 7 + 2
|
const fixedW = 2 + 7 + 2
|
||||||
@@ -136,18 +128,18 @@ func (m *Model) renderList() string {
|
|||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
s.Method(r.Method).Background(selBg).Render(r.Method),
|
style.S.Method(r.Method).Background(selBg).Render(r.Method),
|
||||||
bg.Width(2).Render(""),
|
bg.Width(2).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
|
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
s.Method(r.Method).Render(r.Method),
|
style.S.Method(r.Method).Render(r.Method),
|
||||||
s.Faint.Render(" "),
|
ilovetui.S.Faint.Render(" "),
|
||||||
s.Bold.Render(r.URL.Host),
|
ilovetui.S.Bold.Render(r.URL.Host),
|
||||||
s.Faint.Render(path),
|
ilovetui.S.Faint.Render(path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
@@ -157,17 +149,10 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
func (m *Model) renderResponseList() string {
|
func (m *Model) renderResponseList() string {
|
||||||
if len(m.responseQueue) == 0 {
|
if len(m.responseQueue) == 0 {
|
||||||
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(" (҂◡_◡)\nno response yet"))
|
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(҂◡_◡)", "no response yet")))
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||||
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, resp := range m.responseQueue[start:end] {
|
for i, resp := range m.responseQueue[start:end] {
|
||||||
@@ -185,7 +170,7 @@ func (m *Model) renderResponseList() string {
|
|||||||
statusStr := fmt.Sprintf("%d", code)
|
statusStr := fmt.Sprintf("%d", code)
|
||||||
|
|
||||||
selected := globalIdx == m.responseCursor
|
selected := globalIdx == m.responseCursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
statusSt := style.StatusStyle(code, 7)
|
statusSt := style.StatusStyle(code, 7)
|
||||||
|
|
||||||
@@ -200,7 +185,7 @@ func (m *Model) renderResponseList() string {
|
|||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(2).Render(""),
|
bg.Width(2).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
|
bg.Bold(true).Width(hostPathW).Render(f.Request.URL.Host+path),
|
||||||
@@ -209,9 +194,9 @@ func (m *Model) renderResponseList() string {
|
|||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
s.Faint.Render(" "),
|
ilovetui.S.Faint.Render(" "),
|
||||||
s.Bold.Render(f.Request.URL.Host),
|
ilovetui.S.Bold.Render(f.Request.URL.Host),
|
||||||
s.Faint.Render(path),
|
ilovetui.S.Faint.Render(path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"charm.land/bubbles/v2/textinput"
|
"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"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/plugins"
|
"github.com/anotherhadi/spilltea/internal/plugins"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -36,7 +36,7 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(mgr *plugins.Manager) Model {
|
func New(mgr *plugins.Manager) Model {
|
||||||
ta := style.NewTextarea(true)
|
ta := ilovetui.NewTextarea(true)
|
||||||
ta.Placeholder = "plugin configuration..."
|
ta.Placeholder = "plugin configuration..."
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@ func New(mgr *plugins.Manager) Model {
|
|||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
manager: mgr,
|
manager: mgr,
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
detailViewport: style.NewViewport(),
|
detailViewport: ilovetui.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
pager: style.NewPaginator(),
|
pager: ilovetui.NewPaginator(),
|
||||||
help: style.NewHelp(),
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,14 +70,14 @@ func (m *Model) recalcSizes() {
|
|||||||
}
|
}
|
||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
|
|
||||||
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||||
|
|
||||||
inner := m.width - 2
|
inner := m.width - 2
|
||||||
if inner < 0 {
|
if inner < 0 {
|
||||||
inner = 0
|
inner = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||||
if listVH < 0 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ func (m *Model) recalcSizes() {
|
|||||||
|
|
||||||
m.filterInput.SetWidth(inner - 2)
|
m.filterInput.SetWidth(inner - 2)
|
||||||
|
|
||||||
detailContentH := style.PanelContentH(detailH)
|
detailContentH := ilovetui.ContentHeight(detailH)
|
||||||
const headerH = 2
|
const headerH = 2
|
||||||
const configFixedH = 2 // blank line + label line
|
const configFixedH = 2 // blank line + label line
|
||||||
textareaH := max(3, detailContentH/3)
|
textareaH := max(3, detailContentH/3)
|
||||||
@@ -112,7 +112,6 @@ func (m *Model) recalcSizes() {
|
|||||||
m.syncDetailViewport()
|
m.syncDetailViewport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh reloads the plugin list from the manager.
|
|
||||||
func (m *Model) Refresh() {
|
func (m *Model) Refresh() {
|
||||||
if m.manager == nil {
|
if m.manager == nil {
|
||||||
return
|
return
|
||||||
@@ -187,15 +186,21 @@ func (m *Model) syncDetailViewport() {
|
|||||||
|
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
type pluginsKeyMap struct {
|
type pluginsKeyMap struct {
|
||||||
editing bool
|
editing bool
|
||||||
hasConfig bool
|
hasConfig bool
|
||||||
|
width int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k pluginsKeyMap) ShortHelp() []key.Binding {
|
func (k pluginsKeyMap) ShortHelp() []key.Binding {
|
||||||
@@ -210,11 +215,20 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
|
|||||||
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
|
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
|
||||||
)
|
)
|
||||||
if k.hasConfig {
|
if k.hasConfig {
|
||||||
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint}
|
return []key.Binding{pk.Toggle, pk.EditConfig, scrollHint, g.Help}
|
||||||
}
|
}
|
||||||
return []key.Binding{pk.Toggle, pk.Filter, scrollHint}
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,10 @@ import (
|
|||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/keys"
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PluginsChangedMsg is sent when the plugin list should be refreshed.
|
|
||||||
type PluginsChangedMsg struct{}
|
|
||||||
|
|
||||||
// RefreshCmd returns a command that triggers a list refresh.
|
|
||||||
func RefreshCmd() tea.Cmd {
|
|
||||||
return func() tea.Msg { return PluginsChangedMsg{} }
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.(type) {
|
|
||||||
case PluginsChangedMsg:
|
|
||||||
m.Refresh()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route non-key messages to textarea when editing so internal
|
// Route non-key messages to textarea when editing so internal
|
||||||
// textarea messages (e.g. clipboard paste) are handled correctly.
|
// textarea messages (e.g. clipboard paste) are handled correctly.
|
||||||
if m.editing {
|
if m.editing {
|
||||||
@@ -34,12 +21,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseWheelMsg:
|
case tea.MouseWheelMsg:
|
||||||
if !m.editing {
|
if !m.editing {
|
||||||
switch msg.Button {
|
util.HandleMouseWheel(msg, &m.detailViewport)
|
||||||
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:
|
||||||
@@ -77,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Blur()
|
m.textarea.Blur()
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
val := m.textarea.Value()
|
val := m.textarea.Value()
|
||||||
m.manager.SaveConfig(info.Name, val)
|
m.manager.SaveConfig(info.ID, val)
|
||||||
// Update cached info.
|
// Update cached info.
|
||||||
m.filtered[m.cursor].ConfigText = val
|
m.filtered[m.cursor].ConfigText = val
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].ConfigText = val
|
m.items[i].ConfigText = val
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -125,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, pk.Toggle):
|
case key.Matches(msg, pk.Toggle):
|
||||||
if info, ok := m.selected(); ok && m.manager != nil {
|
if info, ok := m.selected(); ok && m.manager != nil {
|
||||||
m.manager.TogglePlugin(info.Name)
|
m.manager.TogglePlugin(info.ID)
|
||||||
m.filtered[m.cursor].Enabled = !info.Enabled
|
m.filtered[m.cursor].Enabled = !info.Enabled
|
||||||
for i := range m.items {
|
for i := range m.items {
|
||||||
if m.items[i].Name == info.Name {
|
if m.items[i].ID == info.ID {
|
||||||
m.items[i].Enabled = !info.Enabled
|
m.items[i].Enabled = !info.Enabled
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -142,19 +124,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.textarea.Focus()
|
m.textarea.Focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, g.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, false)
|
||||||
|
m.recalcSizes()
|
||||||
|
m.syncTextarea()
|
||||||
|
m.detailViewport.GotoTop()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.filtered), m.pager.PerPage, true)
|
||||||
|
m.recalcSizes()
|
||||||
|
m.syncTextarea()
|
||||||
|
m.detailViewport.GotoTop()
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.detailViewport.Height() / 2
|
util.ScrollViewport(&m.detailViewport, -1)
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.detailViewport.Height() / 2
|
util.ScrollViewport(&m.detailViewport, 1)
|
||||||
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
|
||||||
|
|||||||
+38
-45
@@ -7,18 +7,18 @@ import (
|
|||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/glamour/v2"
|
"charm.land/glamour/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/anotherhadi/spilltea/internal/config"
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
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, ilovetui.S.Faint.Render(util.EmptyState(m.width, "(._.)~*.'", "no plugins loaded"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
|
listH, detailH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.4)
|
||||||
|
|
||||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
m.renderListPanel(m.width, listH),
|
m.renderListPanel(m.width, listH),
|
||||||
@@ -29,33 +29,34 @@ 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
|
panelStyle := ilovetui.S.PanelFocused
|
||||||
panelStyle := s.PanelFocused
|
|
||||||
if m.editing {
|
if m.editing {
|
||||||
panelStyle = s.Panel
|
panelStyle = ilovetui.S.Panel
|
||||||
|
}
|
||||||
|
var dots string
|
||||||
|
if len(m.filtered) > 0 {
|
||||||
|
dots = ilovetui.S.Faint.Render(m.pager.View())
|
||||||
}
|
}
|
||||||
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(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
|
return ilovetui.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
|
panelStyle := ilovetui.S.Panel
|
||||||
panelStyle := s.Panel
|
|
||||||
if m.editing {
|
if m.editing {
|
||||||
panelStyle = s.PanelFocused
|
panelStyle = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
info, ok := m.selected()
|
info, ok := m.selected()
|
||||||
if !ok {
|
if !ok {
|
||||||
return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
|
return ilovetui.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", "", m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
statusSt := lipgloss.NewStyle().Foreground(s.Error)
|
statusSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||||
if info.Enabled {
|
if info.Enabled {
|
||||||
statusSt = lipgloss.NewStyle().Foreground(s.Success)
|
statusSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||||
}
|
}
|
||||||
status := "disabled"
|
status := "disabled"
|
||||||
if info.Enabled {
|
if info.Enabled {
|
||||||
@@ -65,26 +66,26 @@ func (m *Model) renderDetailPanel(h int) string {
|
|||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
header := pad.Render(
|
header := pad.Render(
|
||||||
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
ilovetui.S.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
||||||
s.Faint.Render(filepath.Base(info.FilePath)),
|
ilovetui.S.Faint.Render(filepath.Base(info.FilePath)),
|
||||||
)
|
)
|
||||||
|
|
||||||
parts := []string{header, m.detailViewport.View()}
|
parts := []string{header, ilovetui.ViewportView(&m.detailViewport)}
|
||||||
|
|
||||||
if m.hasConfig() {
|
if m.hasConfig() {
|
||||||
var configLabel string
|
var configLabel string
|
||||||
if m.editing {
|
if m.editing {
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
|
configLabel = pad.Render(ilovetui.S.Faint.Render("editing config (" + escKey + " to save):"))
|
||||||
} else {
|
} else {
|
||||||
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
editKey := keys.Keys.Plugins.EditConfig.Help().Key
|
||||||
configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
|
configLabel = pad.Render(ilovetui.S.Faint.Render("config (" + editKey + " to edit):"))
|
||||||
}
|
}
|
||||||
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
|
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
|
||||||
}
|
}
|
||||||
|
|
||||||
inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
return style.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
|
return ilovetui.RenderWithTitle(panelStyle, icons.I.Detail+"Detail", inner, m.width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderPluginDescription(desc string, width int) string {
|
func renderPluginDescription(desc string, width int) string {
|
||||||
@@ -96,7 +97,7 @@ func renderPluginDescription(desc string, width int) string {
|
|||||||
desc = strings.Join(lines, "\n")
|
desc = strings.Join(lines, "\n")
|
||||||
|
|
||||||
r, err := glamour.NewTermRenderer(
|
r, err := glamour.NewTermRenderer(
|
||||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||||
glamour.WithWordWrap(width),
|
glamour.WithWordWrap(width),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,52 +111,44 @@ func renderPluginDescription(desc string, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
s := style.S
|
|
||||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
||||||
if m.filtering {
|
if m.filtering {
|
||||||
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
|
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||||
}
|
}
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
escKey := keys.Keys.Global.Escape.Help().Key
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
accent := lipgloss.NewStyle().Foreground(ilovetui.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) + " " + ilovetui.S.Bold.Render(m.filter) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()})))
|
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, hasConfig: m.hasConfig()}))
|
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 {
|
||||||
s := style.S
|
|
||||||
if len(m.filtered) == 0 {
|
if len(m.filtered) == 0 {
|
||||||
msg := " (ง •̀_•́)ง\nno plugins"
|
msg := util.EmptyState(m.listViewport.Width(), "(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||||
if m.filter != "" {
|
if m.filter != "" {
|
||||||
msg = " = _ =\nno results"
|
msg = util.EmptyState(m.listViewport.Width(), "= _ =", "no results")
|
||||||
}
|
}
|
||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
s.Faint.Render(msg),
|
ilovetui.S.Faint.Render(msg),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, p := range m.filtered[start:end] {
|
for i, p := range m.filtered[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
|
|
||||||
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
|
enabledSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||||
enabledStr := "off"
|
enabledStr := "off"
|
||||||
if p.Enabled {
|
if p.Enabled {
|
||||||
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
|
enabledSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||||
enabledStr = "on "
|
enabledStr = "on "
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,10 +161,10 @@ func (m *Model) renderList() string {
|
|||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
|
enabledSt.Background(ilovetui.S.Selection).Width(3).Render(enabledStr),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(nameW).Render(p.Name),
|
bg.Bold(true).Width(nameW).Render(p.Name),
|
||||||
)
|
)
|
||||||
@@ -180,7 +173,7 @@ func (m *Model) renderList() string {
|
|||||||
" ",
|
" ",
|
||||||
enabledSt.Width(3).Render(enabledStr),
|
enabledSt.Width(3).Render(enabledStr),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(p.Name),
|
ilovetui.S.Bold.Render(p.Name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
|
|||||||
+114
-15
@@ -2,16 +2,18 @@ package replay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/help"
|
"charm.land/bubbles/v2/help"
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
"charm.land/bubbles/v2/paginator"
|
"charm.land/bubbles/v2/paginator"
|
||||||
"charm.land/bubbles/v2/textarea"
|
"charm.land/bubbles/v2/textarea"
|
||||||
|
"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"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendToReplayMsg struct {
|
type SendToReplayMsg struct {
|
||||||
@@ -31,15 +33,31 @@ type Entry struct {
|
|||||||
ResponseRaw string // filled after send
|
ResponseRaw string // filled after send
|
||||||
StatusCode int // 0 = not sent yet
|
StatusCode int // 0 = not sent yet
|
||||||
Sending bool
|
Sending bool
|
||||||
|
Flagged bool
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type panel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
panelList panel = iota
|
||||||
|
panelRequest
|
||||||
|
panelResponse
|
||||||
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
entries []Entry
|
allEntries []Entry
|
||||||
|
entries []Entry // filtered view; equals allEntries when no filter
|
||||||
|
entryIndices []int // maps entries[i] -> allEntries[i]; nil when no filter
|
||||||
cursor int
|
cursor int
|
||||||
editing bool
|
editing bool
|
||||||
|
focusedPanel panel
|
||||||
database *db.DB
|
database *db.DB
|
||||||
|
|
||||||
|
filterInput textinput.Model
|
||||||
|
filterActive bool
|
||||||
|
filterAccepted bool
|
||||||
|
|
||||||
listViewport viewport.Model
|
listViewport viewport.Model
|
||||||
requestViewport viewport.Model
|
requestViewport viewport.Model
|
||||||
responseViewport viewport.Model
|
responseViewport viewport.Model
|
||||||
@@ -52,26 +70,36 @@ type Model struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
ta := style.NewTextarea(false)
|
ta := ilovetui.NewTextarea(false)
|
||||||
ta.Blur()
|
ta.Blur()
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = ""
|
||||||
return Model{
|
return Model{
|
||||||
listViewport: style.NewViewport(),
|
listViewport: ilovetui.NewViewport(),
|
||||||
requestViewport: style.NewViewport(),
|
requestViewport: ilovetui.NewViewport(),
|
||||||
responseViewport: style.NewViewport(),
|
responseViewport: ilovetui.NewViewport(),
|
||||||
textarea: ta,
|
textarea: ta,
|
||||||
pager: style.NewPaginator(),
|
filterInput: ti,
|
||||||
help: style.NewHelp(),
|
pager: ilovetui.NewPaginator(),
|
||||||
|
help: ilovetui.NewHelp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
func (m Model) IsEditing() bool { return m.editing }
|
func (m Model) IsEditing() bool { return m.editing || (m.filterActive && !m.filterAccepted) }
|
||||||
|
|
||||||
|
func (m Model) IsResponseFocused() bool {
|
||||||
|
return m.focusedPanel == panelResponse
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) CurrentRaw() string {
|
func (m Model) CurrentRaw() string {
|
||||||
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.entries[m.cursor].ResponseRaw
|
||||||
|
}
|
||||||
return m.entries[m.cursor].RequestRaw
|
return m.entries[m.cursor].RequestRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, dbe := range entries {
|
for _, dbe := range entries {
|
||||||
m.entries = append(m.entries, entryFromDB(dbe))
|
m.allEntries = append(m.allEntries, entryFromDB(dbe))
|
||||||
}
|
}
|
||||||
|
m.entries = m.allEntries
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
m.cursor = len(m.entries) - 1
|
m.cursor = len(m.entries) - 1
|
||||||
@@ -120,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
|
|||||||
RequestRaw: dbe.RequestRaw,
|
RequestRaw: dbe.RequestRaw,
|
||||||
ResponseRaw: dbe.ResponseRaw,
|
ResponseRaw: dbe.ResponseRaw,
|
||||||
StatusCode: dbe.StatusCode,
|
StatusCode: dbe.StatusCode,
|
||||||
|
Flagged: dbe.Flagged,
|
||||||
Err: err,
|
Err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,14 +162,15 @@ func (m *Model) SetSize(w, h int) {
|
|||||||
|
|
||||||
func (m *Model) recalcSizes() {
|
func (m *Model) recalcSizes() {
|
||||||
m.help.SetWidth(m.width - 2)
|
m.help.SetWidth(m.width - 2)
|
||||||
|
m.filterInput.SetWidth(m.width - 4)
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
|
|
||||||
listInner := m.width - 2
|
listInner := m.width - 2
|
||||||
if listInner < 0 {
|
if listInner < 0 {
|
||||||
listInner = 0
|
listInner = 0
|
||||||
}
|
}
|
||||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||||
if listVH < 0 {
|
if listVH < 0 {
|
||||||
listVH = 0
|
listVH = 0
|
||||||
}
|
}
|
||||||
@@ -159,7 +190,7 @@ func (m *Model) recalcSizes() {
|
|||||||
if rightInner < 0 {
|
if rightInner < 0 {
|
||||||
rightInner = 0
|
rightInner = 0
|
||||||
}
|
}
|
||||||
bodyVH := style.PanelContentH(bodyH)
|
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||||
|
|
||||||
m.requestViewport.SetWidth(leftInner)
|
m.requestViewport.SetWidth(leftInner)
|
||||||
m.requestViewport.SetHeight(bodyVH)
|
m.requestViewport.SetHeight(bodyVH)
|
||||||
@@ -178,15 +209,83 @@ func (m *Model) bodyHalfWidths() (left, right int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) currentAllIdx() int {
|
||||||
|
if m.entryIndices != nil && m.cursor < len(m.entryIndices) {
|
||||||
|
return m.entryIndices[m.cursor]
|
||||||
|
}
|
||||||
|
if m.cursor < len(m.allEntries) {
|
||||||
|
return m.cursor
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesFilter(e Entry, term string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(e.Host), term) ||
|
||||||
|
strings.Contains(strings.ToLower(e.Path), term) ||
|
||||||
|
strings.Contains(strings.ToLower(strings.TrimSpace(e.Method)), term)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) applyFilter(preferAllIdx int) {
|
||||||
|
term := strings.ToLower(m.filterInput.Value())
|
||||||
|
if !m.filterActive || term == "" {
|
||||||
|
m.entries = m.allEntries
|
||||||
|
m.entryIndices = nil
|
||||||
|
if preferAllIdx >= 0 && preferAllIdx < len(m.allEntries) {
|
||||||
|
m.cursor = preferAllIdx
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.entries = nil
|
||||||
|
m.entryIndices = nil
|
||||||
|
newCursor := 0
|
||||||
|
found := false
|
||||||
|
for i, e := range m.allEntries {
|
||||||
|
if matchesFilter(e, term) {
|
||||||
|
if i == preferAllIdx && !found {
|
||||||
|
newCursor = len(m.entries)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
m.entries = append(m.entries, e)
|
||||||
|
m.entryIndices = append(m.entryIndices, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
m.cursor = newCursor
|
||||||
|
} else {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.entries) == 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
} else if m.cursor >= len(m.entries) {
|
||||||
|
m.cursor = len(m.entries) - 1
|
||||||
|
}
|
||||||
|
m.pager.SetTotalPages(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) clearFilter() {
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.filterActive = false
|
||||||
|
m.filterAccepted = false
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
}
|
||||||
|
|
||||||
type replayKeyMap struct{ width int }
|
type replayKeyMap struct{ width int }
|
||||||
|
|
||||||
func (replayKeyMap) ShortHelp() []key.Binding {
|
func (replayKeyMap) ShortHelp() []key.Binding {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help}
|
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs, g.SendToDiff}
|
||||||
|
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
|
||||||
|
all = append(all, g.CommonBindings()...)
|
||||||
return keys.ChunkByWidth(all, m.width)
|
return keys.ChunkByWidth(all, m.width)
|
||||||
}
|
}
|
||||||
|
|||||||
+308
-127
@@ -2,20 +2,33 @@ package replay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"compress/zlib"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
"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"
|
||||||
|
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sentMsg struct {
|
type sentMsg struct {
|
||||||
@@ -54,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
entry.DBID = id
|
entry.DBID = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.entries = append(m.entries, entry)
|
m.allEntries = append(m.allEntries, entry)
|
||||||
m.cursor = len(m.entries) - 1
|
m.applyFilter(len(m.allEntries) - 1)
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
|
||||||
m.refreshListViewport()
|
|
||||||
m.refreshBody()
|
|
||||||
|
|
||||||
case sentMsg:
|
case sentMsg:
|
||||||
if msg.index >= 0 && msg.index < len(m.entries) {
|
allIdx := msg.index
|
||||||
e := &m.entries[msg.index]
|
if allIdx >= 0 && allIdx < len(m.allEntries) {
|
||||||
|
e := m.allEntries[allIdx]
|
||||||
e.Sending = false
|
e.Sending = false
|
||||||
e.StatusCode = msg.statusCode
|
e.StatusCode = msg.statusCode
|
||||||
e.ResponseRaw = msg.responseRaw
|
e.ResponseRaw = msg.responseRaw
|
||||||
@@ -71,11 +82,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
e.ResponseRaw = "Error: " + msg.err.Error()
|
e.ResponseRaw = "Error: " + msg.err.Error()
|
||||||
}
|
}
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.UpdateReplayEntry(entryToDB(*e))
|
if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil {
|
||||||
|
log.Printf("replay: update entry: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.refreshListViewport()
|
m.allEntries[allIdx] = e
|
||||||
m.refreshBody()
|
}
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
|
||||||
case util.EditorFinishedMsg:
|
case util.EditorFinishedMsg:
|
||||||
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
|
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
|
||||||
@@ -88,24 +102,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
case tea.MouseWheelUp:
|
case tea.MouseWheelUp:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
|
m.scrollFocusedViewportVertical(-1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelDown:
|
case tea.MouseWheelDown:
|
||||||
if msg.Mod.Contains(tea.ModShift) {
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
} else {
|
} else {
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
|
m.scrollFocusedViewportVertical(1)
|
||||||
}
|
}
|
||||||
case tea.MouseWheelLeft:
|
case tea.MouseWheelLeft:
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.scrollBothViewports(true)
|
||||||
m.responseViewport.ScrollLeft(6)
|
|
||||||
case tea.MouseWheelRight:
|
case tea.MouseWheelRight:
|
||||||
m.requestViewport.ScrollRight(6)
|
m.scrollBothViewports(false)
|
||||||
m.responseViewport.ScrollRight(6)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,29 +132,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
g := keys.Keys.Global
|
g := keys.Keys.Global
|
||||||
r := keys.Keys.Replay
|
r := keys.Keys.Replay
|
||||||
|
|
||||||
|
if m.filterActive && !m.filterAccepted {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, g.Escape):
|
||||||
|
m.clearFilter()
|
||||||
|
case msg.String() == "enter":
|
||||||
|
m.filterAccepted = true
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
default:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
prevAllIdx := m.currentAllIdx()
|
||||||
|
m.applyFilter(prevAllIdx)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filterActive && m.filterAccepted {
|
||||||
|
if key.Matches(msg, g.Escape) {
|
||||||
|
m.clearFilter()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, g.Up):
|
case key.Matches(msg, g.Up):
|
||||||
|
if m.focusedPanel == panelList {
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
m.scrollFocusedViewportVertical(-1)
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, g.Down):
|
case key.Matches(msg, g.Down):
|
||||||
|
if m.focusedPanel == panelList {
|
||||||
if m.cursor < len(m.entries)-1 {
|
if m.cursor < len(m.entries)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
m.scrollFocusedViewportVertical(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, g.CycleFocus):
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelList:
|
||||||
|
m.focusedPanel = panelRequest
|
||||||
|
case panelRequest:
|
||||||
|
m.focusedPanel = panelResponse
|
||||||
|
default:
|
||||||
|
m.focusedPanel = panelList
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Send):
|
case key.Matches(msg, r.Send):
|
||||||
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
|
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
|
||||||
m.entries[m.cursor].Sending = true
|
allIdx := m.currentAllIdx()
|
||||||
m.entries[m.cursor].ResponseRaw = ""
|
m.allEntries[allIdx].Sending = true
|
||||||
m.entries[m.cursor].Err = nil
|
m.allEntries[allIdx].ResponseRaw = ""
|
||||||
m.refreshListViewport()
|
m.allEntries[allIdx].Err = nil
|
||||||
m.refreshBody()
|
entry := m.allEntries[allIdx]
|
||||||
return m, sendCmd(m.entries[m.cursor], m.cursor)
|
m.applyFilter(allIdx)
|
||||||
|
return m, sendCmd(entry, allIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Edit):
|
case key.Matches(msg, r.Edit):
|
||||||
@@ -156,6 +211,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, r.EditExt):
|
case key.Matches(msg, r.EditExt):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m, util.OpenExternalEditorView(m.entries[m.cursor].ResponseRaw)
|
||||||
|
}
|
||||||
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
|
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,18 +224,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollUp):
|
case key.Matches(msg, g.ScrollUp):
|
||||||
step := m.responseViewport.Height() / 2
|
vp := m.focusedViewport()
|
||||||
if step < 1 {
|
util.ScrollViewport(&vp, -1)
|
||||||
step = 1
|
m.setFocusedViewport(vp)
|
||||||
}
|
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.ScrollDown):
|
case key.Matches(msg, g.ScrollDown):
|
||||||
step := m.responseViewport.Height() / 2
|
vp := m.focusedViewport()
|
||||||
if step < 1 {
|
util.ScrollViewport(&vp, 1)
|
||||||
step = 1
|
m.setFocusedViewport(vp)
|
||||||
}
|
|
||||||
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
|
|
||||||
|
|
||||||
case key.Matches(msg, g.Left):
|
case key.Matches(msg, g.Left):
|
||||||
m.requestViewport.ScrollLeft(6)
|
m.requestViewport.ScrollLeft(6)
|
||||||
@@ -187,31 +241,116 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.requestViewport.ScrollRight(6)
|
m.requestViewport.ScrollRight(6)
|
||||||
m.responseViewport.ScrollRight(6)
|
m.responseViewport.ScrollRight(6)
|
||||||
|
|
||||||
|
case key.Matches(msg, r.Flag):
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
allIdx := m.currentAllIdx()
|
||||||
|
if m.database != nil && m.allEntries[allIdx].DBID != 0 {
|
||||||
|
if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil {
|
||||||
|
log.Printf("replay: toggle flag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged
|
||||||
|
m.applyFilter(allIdx)
|
||||||
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.Delete):
|
case key.Matches(msg, r.Delete):
|
||||||
if len(m.entries) > 0 {
|
if len(m.entries) > 0 {
|
||||||
e := m.entries[m.cursor]
|
allIdx := m.currentAllIdx()
|
||||||
|
e := m.allEntries[allIdx]
|
||||||
if m.database != nil && e.DBID != 0 {
|
if m.database != nil && e.DBID != 0 {
|
||||||
m.database.DeleteReplayEntry(e.DBID)
|
if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
|
||||||
|
log.Printf("replay: delete entry: %v", err)
|
||||||
}
|
}
|
||||||
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
|
|
||||||
if m.cursor >= len(m.entries) && m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
}
|
||||||
m.pager.SetTotalPages(len(m.entries))
|
m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
|
||||||
m.refreshListViewport()
|
preferAllIdx := allIdx
|
||||||
m.refreshBody()
|
if preferAllIdx >= len(m.allEntries) {
|
||||||
|
preferAllIdx = len(m.allEntries) - 1
|
||||||
|
}
|
||||||
|
m.applyFilter(preferAllIdx)
|
||||||
}
|
}
|
||||||
|
|
||||||
case key.Matches(msg, r.DeleteAll):
|
case key.Matches(msg, r.DeleteAll):
|
||||||
if m.database != nil {
|
if m.database != nil {
|
||||||
m.database.DeleteAllReplayEntries()
|
if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil {
|
||||||
|
log.Printf("replay: delete all entries: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mirror the DB logic in memory: delete unflagged first; if none, delete all.
|
||||||
|
hasUnflagged := false
|
||||||
|
for _, e := range m.allEntries {
|
||||||
|
if !e.Flagged {
|
||||||
|
hasUnflagged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUnflagged {
|
||||||
|
filtered := m.allEntries[:0]
|
||||||
|
for _, e := range m.allEntries {
|
||||||
|
if e.Flagged {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.allEntries = filtered
|
||||||
|
} else {
|
||||||
|
m.allEntries = nil
|
||||||
}
|
}
|
||||||
m.entries = nil
|
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.pager.SetTotalPages(0)
|
m.filterActive = false
|
||||||
|
m.filterAccepted = false
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.recalcSizes()
|
||||||
|
m.applyFilter(-1)
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoTop):
|
||||||
|
m.cursor = 0
|
||||||
|
m.pager.Page = 0
|
||||||
m.refreshListViewport()
|
m.refreshListViewport()
|
||||||
m.refreshBody()
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.GotoBottom):
|
||||||
|
m.cursor = util.CursorGotoBottom(len(m.entries))
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.PrevPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, false)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, keys.Keys.Global.NextPage):
|
||||||
|
m.cursor = util.CursorMovePage(m.cursor, len(m.entries), m.pager.PerPage, true)
|
||||||
|
m.refreshListViewport()
|
||||||
|
m.refreshBody()
|
||||||
|
|
||||||
|
case key.Matches(msg, g.SendToDiff):
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
e := m.entries[m.cursor]
|
||||||
|
var raw, label string
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
raw = e.ResponseRaw
|
||||||
|
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
|
||||||
|
} else {
|
||||||
|
raw = e.RequestRaw
|
||||||
|
label = e.Method + " " + e.Host + e.Path
|
||||||
|
}
|
||||||
|
if raw != "" {
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case key.Matches(msg, r.Filter):
|
||||||
|
if m.focusedPanel == panelList {
|
||||||
|
m.filterActive = true
|
||||||
|
m.filterAccepted = false
|
||||||
|
m.filterInput.Placeholder = "filter requests..."
|
||||||
|
m.filterInput.Focus()
|
||||||
|
m.recalcSizes()
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -239,11 +378,49 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// focusedViewport returns the viewport that should receive scroll events.
|
||||||
|
// When the list is focused, scroll targets the request panel.
|
||||||
|
func (m *Model) focusedViewport() viewport.Model {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
return m.responseViewport
|
||||||
|
}
|
||||||
|
return m.requestViewport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) setFocusedViewport(vp viewport.Model) {
|
||||||
|
if m.focusedPanel == panelResponse {
|
||||||
|
m.responseViewport = vp
|
||||||
|
} else {
|
||||||
|
m.requestViewport = vp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollBothViewports(left bool) {
|
||||||
|
if left {
|
||||||
|
m.requestViewport.ScrollLeft(6)
|
||||||
|
m.responseViewport.ScrollLeft(6)
|
||||||
|
} else {
|
||||||
|
m.requestViewport.ScrollRight(6)
|
||||||
|
m.responseViewport.ScrollRight(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollFocusedViewportVertical(delta int) {
|
||||||
|
vp := m.focusedViewport()
|
||||||
|
vp.SetYOffset(vp.YOffset() + delta)
|
||||||
|
m.setFocusedViewport(vp)
|
||||||
|
}
|
||||||
|
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,79 +436,72 @@ 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, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "(ノ◕ヮ◕)ノ*:・゚", "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, ilovetui.S.Faint.Render(util.EmptyState(m.responseViewport.Width(), "( •_•)>⌐■", "press send to fire"))))
|
||||||
}
|
}
|
||||||
m.responseViewport.SetYOffset(0)
|
m.responseViewport.SetYOffset(0)
|
||||||
m.responseViewport.SetXOffset(0)
|
m.responseViewport.SetXOffset(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
||||||
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n")
|
parsed := util.ParseRawRequest(entry.RequestRaw)
|
||||||
if len(lines) == 0 {
|
if parsed.Method == "" {
|
||||||
return "", 0, fmt.Errorf("empty request")
|
return "", 0, fmt.Errorf("empty request")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(lines[0], " ", 3)
|
host := parsed.Host
|
||||||
if len(parts) < 2 {
|
if host == "" {
|
||||||
return "", 0, fmt.Errorf("invalid request line")
|
host = entry.Host
|
||||||
}
|
}
|
||||||
method := strings.TrimSpace(parts[0])
|
|
||||||
path := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
headers := make(http.Header)
|
headers := make(http.Header)
|
||||||
host := entry.Host
|
for _, h := range parsed.Headers {
|
||||||
i := 1
|
if strings.EqualFold(h.Key, "host") {
|
||||||
for i < len(lines) {
|
continue
|
||||||
line := strings.TrimRight(lines[i], "\r")
|
|
||||||
if line == "" {
|
|
||||||
i++
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
headers.Add(h.Key, h.Value)
|
||||||
k := strings.TrimSpace(kv[0])
|
|
||||||
v := strings.TrimSpace(kv[1])
|
|
||||||
if strings.ToLower(k) == "host" {
|
|
||||||
host = v
|
|
||||||
} else {
|
|
||||||
headers.Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyBytes []byte
|
|
||||||
if i < len(lines) {
|
|
||||||
b := strings.Join(lines[i:], "\n")
|
|
||||||
b = strings.TrimRight(b, "\n")
|
|
||||||
bodyBytes = []byte(b)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scheme := entry.Scheme
|
scheme := entry.Scheme
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
urlStr := scheme + "://" + host + path
|
urlStr := scheme + "://" + host + parsed.Path
|
||||||
|
|
||||||
var bodyReader io.Reader
|
var bodyReader io.Reader
|
||||||
if len(bodyBytes) > 0 {
|
if parsed.Body != "" {
|
||||||
bodyReader = bytes.NewReader(bodyBytes)
|
bodyReader = strings.NewReader(parsed.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(method, urlStr, bodyReader)
|
req, err := http.NewRequest(parsed.Method, urlStr, bodyReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
req.Header = headers
|
req.Header = headers
|
||||||
|
|
||||||
|
tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config
|
||||||
|
if !config.Global.App.SslInsecure {
|
||||||
|
if certPool, err := x509.SystemCertPool(); err == nil {
|
||||||
|
caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem")
|
||||||
|
if pem, err := os.ReadFile(caPath); err == nil {
|
||||||
|
certPool.AppendCertsFromPEM(pem)
|
||||||
|
}
|
||||||
|
tlsCfg.RootCAs = certPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: tlsCfg,
|
||||||
|
}
|
||||||
|
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||||
|
if proxyURL, err := url.Parse(up); err == nil {
|
||||||
|
transport.Proxy = http.ProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: transport,
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
|
||||||
},
|
|
||||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -343,19 +513,21 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||||
|
|
||||||
|
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
|
||||||
|
if decoded, decErr := decodeBody(enc, respBody); decErr == nil {
|
||||||
|
respBody = decoded
|
||||||
|
resp.Header.Del("Content-Encoding")
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
|
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||||
sortedKeys := make([]string, 0, len(resp.Header))
|
for _, line := range util.SortedHeaderLines(resp.Header) {
|
||||||
for k := range resp.Header {
|
sb.WriteString(line)
|
||||||
sortedKeys = append(sortedKeys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(sortedKeys)
|
|
||||||
for _, k := range sortedKeys {
|
|
||||||
for _, v := range resp.Header[k] {
|
|
||||||
fmt.Fprintf(&sb, "%s: %s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.Write(respBody)
|
sb.Write(respBody)
|
||||||
@@ -363,6 +535,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
|
|||||||
return sb.String(), resp.StatusCode, nil
|
return sb.String(), resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeBody(encoding string, body []byte) ([]byte, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "gzip":
|
||||||
|
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
case "br":
|
||||||
|
return io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
|
||||||
|
case "deflate":
|
||||||
|
r, err := zlib.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
case "zstd":
|
||||||
|
r, err := zstd.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
return io.ReadAll(r)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
func entryToDB(e Entry) db.ReplayEntry {
|
func entryToDB(e Entry) db.ReplayEntry {
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if e.Err != nil {
|
if e.Err != nil {
|
||||||
@@ -380,11 +581,16 @@ func entryToDB(e Entry) db.ReplayEntry {
|
|||||||
ResponseRaw: e.ResponseRaw,
|
ResponseRaw: e.ResponseRaw,
|
||||||
StatusCode: e.StatusCode,
|
StatusCode: e.StatusCode,
|
||||||
ErrorMsg: errMsg,
|
ErrorMsg: errMsg,
|
||||||
|
Flagged: e.Flagged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func entryFromMsg(msg SendToReplayMsg) Entry {
|
func entryFromMsg(msg SendToReplayMsg) Entry {
|
||||||
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host)
|
parsed := util.ParseRawRequest(msg.RequestRaw)
|
||||||
|
host := parsed.Host
|
||||||
|
if host == "" {
|
||||||
|
host = msg.Host
|
||||||
|
}
|
||||||
scheme := msg.Scheme
|
scheme := msg.Scheme
|
||||||
if scheme == "" {
|
if scheme == "" {
|
||||||
scheme = util.InferScheme(host)
|
scheme = util.InferScheme(host)
|
||||||
@@ -392,34 +598,9 @@ func entryFromMsg(msg SendToReplayMsg) Entry {
|
|||||||
return Entry{
|
return Entry{
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: path,
|
Path: parsed.Path,
|
||||||
Method: method,
|
Method: parsed.Method,
|
||||||
OriginalRaw: msg.RequestRaw,
|
OriginalRaw: msg.RequestRaw,
|
||||||
RequestRaw: msg.RequestRaw,
|
RequestRaw: msg.RequestRaw,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
|
|
||||||
host = fallbackHost
|
|
||||||
path = "/"
|
|
||||||
lines := strings.SplitN(raw, "\n", 2)
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts := strings.Fields(lines[0])
|
|
||||||
if len(parts) >= 1 {
|
|
||||||
method = parts[0]
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
path = parts[1]
|
|
||||||
}
|
|
||||||
if len(lines) > 1 {
|
|
||||||
for _, line := range strings.Split(lines[1], "\n") {
|
|
||||||
if strings.HasPrefix(strings.ToLower(line), "host:") {
|
|
||||||
host = strings.TrimSpace(line[5:])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|||||||
+59
-34
@@ -6,8 +6,11 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
ilovetui "github.com/anotherhadi/ilovetui"
|
||||||
"github.com/anotherhadi/spilltea/internal/icons"
|
"github.com/anotherhadi/spilltea/internal/icons"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/keys"
|
||||||
"github.com/anotherhadi/spilltea/internal/style"
|
"github.com/anotherhadi/spilltea/internal/style"
|
||||||
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
func (m Model) View() tea.View {
|
||||||
@@ -15,7 +18,7 @@ func (m Model) View() tea.View {
|
|||||||
return tea.NewView("Loading...")
|
return tea.NewView("Loading...")
|
||||||
}
|
}
|
||||||
|
|
||||||
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
|
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
|
||||||
leftW, rightW := m.bodyHalfWidths()
|
leftW, rightW := m.bodyHalfWidths()
|
||||||
|
|
||||||
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
@@ -32,39 +35,57 @@ 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
|
panelStyle := ilovetui.S.Panel
|
||||||
panelStyle := s.PanelFocused
|
if !m.editing && m.focusedPanel == panelList {
|
||||||
if m.editing {
|
panelStyle = ilovetui.S.PanelFocused
|
||||||
panelStyle = s.Panel
|
}
|
||||||
|
var dots string
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
dots = ilovetui.S.Faint.Render(m.pager.View())
|
||||||
}
|
}
|
||||||
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(panelStyle, icons.I.Replay+"Replay", inner, w, h)
|
return ilovetui.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 {
|
||||||
s := style.S
|
|
||||||
var body string
|
var body string
|
||||||
border := s.Panel
|
border := ilovetui.S.Panel
|
||||||
if m.editing {
|
if m.editing {
|
||||||
body = m.textarea.View()
|
body = m.textarea.View()
|
||||||
border = s.PanelFocused
|
border = ilovetui.S.PanelFocused
|
||||||
} else {
|
} else {
|
||||||
body = m.requestViewport.View()
|
body = ilovetui.ViewportView(&m.requestViewport)
|
||||||
|
if m.focusedPanel == panelRequest {
|
||||||
|
border = ilovetui.S.PanelFocused
|
||||||
}
|
}
|
||||||
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
|
}
|
||||||
|
return ilovetui.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderResponsePanel(w, h int) string {
|
func (m *Model) renderResponsePanel(w, h int) string {
|
||||||
s := style.S
|
border := ilovetui.S.Panel
|
||||||
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h)
|
if !m.editing && m.focusedPanel == panelResponse {
|
||||||
|
border = ilovetui.S.PanelFocused
|
||||||
|
}
|
||||||
|
return ilovetui.RenderWithTitle(border, icons.I.Response+"Response", ilovetui.ViewportView(&m.responseViewport), w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderStatusBar() string {
|
func (m *Model) renderStatusBar() string {
|
||||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width}))
|
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
if m.filterActive {
|
||||||
|
filterKey := keys.Keys.Replay.Filter.Help().Key
|
||||||
|
escKey := keys.Keys.Global.Escape.Help().Key
|
||||||
|
if m.filterAccepted {
|
||||||
|
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filterInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(replayKeyMap{width: m.width})))
|
||||||
|
}
|
||||||
|
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||||
|
}
|
||||||
|
return pad.Render(m.help.View(replayKeyMap{width: m.width}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderList() string {
|
func (m *Model) renderList() string {
|
||||||
@@ -72,54 +93,58 @@ func (m *Model) renderList() string {
|
|||||||
return lipgloss.Place(
|
return lipgloss.Place(
|
||||||
m.listViewport.Width(), m.listViewport.Height(),
|
m.listViewport.Width(), m.listViewport.Height(),
|
||||||
lipgloss.Center, lipgloss.Center,
|
lipgloss.Center, lipgloss.Center,
|
||||||
style.S.Faint.Render(" (╥﹏╥)\nsend a request from History or Intercept"),
|
ilovetui.S.Faint.Render(util.EmptyState(m.listViewport.Width(), "(╥﹏╥)", "send a request from History or Intercept")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := style.S
|
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for i, e := range m.entries[start:end] {
|
for i, e := range m.entries[start:end] {
|
||||||
globalIdx := start + i
|
globalIdx := start + i
|
||||||
selected := globalIdx == m.cursor
|
selected := globalIdx == m.cursor
|
||||||
selBg := s.Selection
|
selBg := ilovetui.S.Selection
|
||||||
|
|
||||||
w := m.listViewport.Width()
|
w := m.listViewport.Width()
|
||||||
const fixedW = 2 + 7 + 1 + 3 + 1
|
const fixedW = 2 + 2 + 7 + 1 + 3 + 1
|
||||||
hostPathW := w - fixedW
|
hostPathW := w - fixedW
|
||||||
if hostPathW < 0 {
|
if hostPathW < 0 {
|
||||||
hostPathW = 0
|
hostPathW = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
statusStr, statusSt := entryStatus(e)
|
statusStr, statusSt := entryStatus(e)
|
||||||
|
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||||
|
|
||||||
var line string
|
var line string
|
||||||
if selected {
|
if selected {
|
||||||
bg := lipgloss.NewStyle().Background(selBg)
|
bg := lipgloss.NewStyle().Background(selBg)
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||||
|
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
statusSt.Background(selBg).Render(statusStr),
|
statusSt.Background(selBg).Render(statusStr),
|
||||||
bg.Width(1).Render(""),
|
bg.Width(1).Render(""),
|
||||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
flagStr := " "
|
||||||
|
if e.Flagged {
|
||||||
|
flagStr = icons.I.Flag + " "
|
||||||
|
}
|
||||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||||
" ",
|
" ",
|
||||||
s.Method(e.Method).Render(e.Method),
|
flagSt.Width(2).Render(flagStr),
|
||||||
|
style.S.Method(e.Method).Render(e.Method),
|
||||||
" ",
|
" ",
|
||||||
statusSt.Render(statusStr),
|
statusSt.Render(statusStr),
|
||||||
" ",
|
" ",
|
||||||
s.Bold.Render(e.Host),
|
ilovetui.S.Bold.Render(e.Host),
|
||||||
s.Faint.Render(e.Path),
|
ilovetui.S.Faint.Render(e.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sb.WriteString(line + "\n")
|
sb.WriteString(line + "\n")
|
||||||
@@ -131,11 +156,11 @@ func entryStatus(e Entry) (string, lipgloss.Style) {
|
|||||||
base := lipgloss.NewStyle().Bold(true).Width(3)
|
base := lipgloss.NewStyle().Bold(true).Width(3)
|
||||||
switch {
|
switch {
|
||||||
case e.Sending:
|
case e.Sending:
|
||||||
return "···", base.Foreground(style.S.Subtle)
|
return "···", base.Foreground(ilovetui.S.Subtle)
|
||||||
case e.Err != nil:
|
case e.Err != nil:
|
||||||
return "ERR", base.Foreground(style.S.Error)
|
return "ERR", base.Foreground(ilovetui.S.Error)
|
||||||
case e.StatusCode == 0:
|
case e.StatusCode == 0:
|
||||||
return "---", base.Foreground(style.S.Subtle)
|
return "---", base.Foreground(ilovetui.S.Subtle)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
|
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
// CursorMovePage moves cursor forward or backward by one page (perPage items),
|
||||||
|
// clamped to [0, total-1].
|
||||||
|
func CursorMovePage(cursor, total, perPage int, forward bool) int {
|
||||||
|
step := perPage
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
if forward {
|
||||||
|
cursor += step
|
||||||
|
} else {
|
||||||
|
cursor -= step
|
||||||
|
}
|
||||||
|
if cursor < 0 || total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if cursor >= total {
|
||||||
|
return total - 1
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// CursorGotoBottom returns the last valid cursor index for a list of total items.
|
||||||
|
func CursorGotoBottom(total int) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total - 1
|
||||||
|
}
|
||||||
+33
-6
@@ -1,10 +1,13 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/spilltea/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EditorFinishedMsg struct {
|
type EditorFinishedMsg struct {
|
||||||
@@ -12,27 +15,51 @@ type EditorFinishedMsg struct {
|
|||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenExternalEditor(content string) tea.Cmd {
|
func ResolveEditor() string {
|
||||||
editor := os.Getenv("EDITOR")
|
editor := config.Global.App.ExternalEditor
|
||||||
|
if editor == "" {
|
||||||
|
editor = os.Getenv("EDITOR")
|
||||||
|
}
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
editor = "vi"
|
editor = "vi"
|
||||||
}
|
}
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
|
||||||
|
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("editor: create temp file: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
tmpPath := f.Name()
|
tmpPath := f.Name()
|
||||||
_, _ = f.WriteString(content)
|
if _, err := f.WriteString(content); err != nil {
|
||||||
f.Close()
|
log.Printf("editor: writing temp file: %v", err)
|
||||||
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
log.Printf("editor: closing temp file: %v", err)
|
||||||
|
}
|
||||||
|
return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
|
||||||
defer os.Remove(tmpPath)
|
defer os.Remove(tmpPath)
|
||||||
|
return callback(tmpPath, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenExternalEditor(content string) tea.Cmd {
|
||||||
|
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return EditorFinishedMsg{Err: err}
|
return EditorFinishedMsg{Err: err}
|
||||||
}
|
}
|
||||||
data, readErr := os.ReadFile(tmpPath)
|
data, readErr := os.ReadFile(tmpPath) // #nosec G304 -- tmpPath is from os.CreateTemp, controlled by this process
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return EditorFinishedMsg{Err: readErr}
|
return EditorFinishedMsg{Err: readErr}
|
||||||
}
|
}
|
||||||
return EditorFinishedMsg{Content: string(data)}
|
return EditorFinishedMsg{Content: string(data)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func OpenExternalEditorView(content string) tea.Cmd {
|
||||||
|
return openWithEditor(content, func(_ string, _ error) tea.Msg {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RawRequest holds a parsed raw HTTP request string.
|
||||||
|
type RawRequest struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Proto string
|
||||||
|
Host string
|
||||||
|
Headers []RawHeader
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawHeader is a single header key/value pair preserving insertion order.
|
||||||
|
type RawHeader struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRawRequest parses a raw HTTP request string (as produced by
|
||||||
|
// FormatRawRequest). The Host header, if present, is extracted into Host
|
||||||
|
// but also kept in Headers.
|
||||||
|
func ParseRawRequest(raw string) RawRequest {
|
||||||
|
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||||
|
var r RawRequest
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(lines[0], " ", 3)
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
r.Method = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
r.Path = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
r.Proto = strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 1
|
||||||
|
for i < len(lines) {
|
||||||
|
line := strings.TrimRight(lines[i], "\r")
|
||||||
|
if line == "" {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
|
||||||
|
k := strings.TrimSpace(kv[0])
|
||||||
|
v := strings.TrimSpace(kv[1])
|
||||||
|
r.Headers = append(r.Headers, RawHeader{k, v})
|
||||||
|
if strings.EqualFold(k, "host") {
|
||||||
|
r.Host = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < len(lines) {
|
||||||
|
r.Body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedHeaderLines returns header lines sorted by key name, formatted as
|
||||||
|
// "Key: Value\n" strings. Useful for deterministic serialisation.
|
||||||
|
func SortedHeaderLines(h http.Header) []string {
|
||||||
|
keys := make([]string, 0, len(h))
|
||||||
|
for k := range h {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var out []string
|
||||||
|
for _, k := range keys {
|
||||||
|
for _, v := range h[k] {
|
||||||
|
out = append(out, fmt.Sprintf("%s: %s\n", k, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+41
-1
@@ -1,6 +1,11 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/paginator"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
func Truncate(s string, max int) string {
|
func Truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
@@ -9,6 +14,41 @@ func Truncate(s string, max int) string {
|
|||||||
return s[:max-1] + "…"
|
return s[:max-1] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmptyState renders centered placeholder lines, word-wrapping to fit maxW.
|
||||||
|
func EmptyState(maxW int, lines ...string) string {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Width(maxW).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CenterLines centers each line horizontally relative to the longest one.
|
||||||
|
func CenterLines(lines ...string) string {
|
||||||
|
maxWidth := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
if w := lipgloss.Width(l); w > maxWidth {
|
||||||
|
maxWidth = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
centered := make([]string, len(lines))
|
||||||
|
for i, l := range lines {
|
||||||
|
centered[i] = lipgloss.PlaceHorizontal(maxWidth, lipgloss.Center, l)
|
||||||
|
}
|
||||||
|
return strings.Join(centered, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageBounds returns clamped start/end indices for rendering a paginated list.
|
||||||
|
func PageBounds(p paginator.Model, total int) (start, end int) {
|
||||||
|
start, end = p.GetSliceBounds(total)
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if end < start {
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// InferScheme returns "http" for port 80, "https" otherwise.
|
// InferScheme returns "http" for port 80, "https" otherwise.
|
||||||
func InferScheme(host string) string {
|
func InferScheme(host string) string {
|
||||||
if strings.HasSuffix(host, ":80") {
|
if strings.HasSuffix(host, ":80") {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScrollViewport scrolls vp vertically by half its height.
|
||||||
|
// delta should be -1 for up, +1 for down.
|
||||||
|
func ScrollViewport(vp *viewport.Model, delta int) {
|
||||||
|
step := vp.Height() / 2
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
vp.SetYOffset(vp.YOffset() + delta*step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMouseWheel applies standard mouse wheel scrolling to vp.
|
||||||
|
// Vertical: one line at a time. Shift+vertical or horizontal: scroll 6 columns.
|
||||||
|
func HandleMouseWheel(msg tea.MouseWheelMsg, vp *viewport.Model) {
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseWheelUp:
|
||||||
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
|
vp.ScrollLeft(6)
|
||||||
|
} else {
|
||||||
|
vp.SetYOffset(vp.YOffset() - 1)
|
||||||
|
}
|
||||||
|
case tea.MouseWheelDown:
|
||||||
|
if msg.Mod.Contains(tea.ModShift) {
|
||||||
|
vp.ScrollRight(6)
|
||||||
|
} else {
|
||||||
|
vp.SetYOffset(vp.YOffset() + 1)
|
||||||
|
}
|
||||||
|
case tea.MouseWheelLeft:
|
||||||
|
vp.ScrollLeft(6)
|
||||||
|
case tea.MouseWheelRight:
|
||||||
|
vp.ScrollRight(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{pkgs}: let
|
||||||
|
defaultCertDir = "$HOME/.local/share/spilltea";
|
||||||
|
defaultHost = "127.0.0.1";
|
||||||
|
defaultPort = "8080";
|
||||||
|
in
|
||||||
|
pkgs.writeShellApplication {
|
||||||
|
name = "spilltea-browser";
|
||||||
|
runtimeInputs = with pkgs; [nss.tools librewolf netcat-gnu procps];
|
||||||
|
text = ''
|
||||||
|
CERT_DIR="${defaultCertDir}"
|
||||||
|
HOST="${defaultHost}"
|
||||||
|
PORT="${defaultPort}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--cert-dir) CERT_DIR="$2"; shift 2 ;;
|
||||||
|
--host) HOST="$2"; shift 2 ;;
|
||||||
|
--port) PORT="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown argument: $1"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
CERT_FILE="$CERT_DIR/mitmproxy-ca-cert.pem"
|
||||||
|
|
||||||
|
if [[ ! -f "$CERT_FILE" ]]; then
|
||||||
|
echo "Certificate not found at $CERT_FILE"
|
||||||
|
echo "Launch spilltea first, or use --cert-dir if you changed cert_dir in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pgrep -x spilltea > /dev/null 2>&1; then
|
||||||
|
echo "spilltea is not running. Launch spilltea first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! nc -z "$HOST" "$PORT" > /dev/null 2>&1; then
|
||||||
|
echo "Nothing is listening on $HOST:$PORT."
|
||||||
|
echo "Is spilltea configured on that port? Use --host and --port if you changed them in your config."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROFILE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$PROFILE"' EXIT
|
||||||
|
|
||||||
|
certutil -N -d sql:"$PROFILE" --empty-password
|
||||||
|
certutil -d sql:"$PROFILE" -A -n "spilltea" -t "CT,," -i "$CERT_FILE"
|
||||||
|
|
||||||
|
cat > "$PROFILE/user.js" <<EOF
|
||||||
|
user_pref("network.proxy.type", 1);
|
||||||
|
user_pref("network.proxy.http", "$HOST");
|
||||||
|
user_pref("network.proxy.http_port", $PORT);
|
||||||
|
user_pref("network.proxy.ssl", "$HOST");
|
||||||
|
user_pref("network.proxy.ssl_port", $PORT);
|
||||||
|
user_pref("network.proxy.no_proxies_on", "");
|
||||||
|
user_pref("network.stricttransportsecurity.preloadlist", false);
|
||||||
|
user_pref("dom.security.https_only_mode", false);
|
||||||
|
EOF
|
||||||
|
|
||||||
|
librewolf --profile "$PROFILE" --no-remote
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
schema = 3
|
||||||
|
|
||||||
|
[mod]
|
||||||
|
[mod.'charm.land/bubbles/v2']
|
||||||
|
version = 'v2.1.0'
|
||||||
|
hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA='
|
||||||
|
|
||||||
|
[mod.'charm.land/bubbletea/v2']
|
||||||
|
version = 'v2.0.6'
|
||||||
|
hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434='
|
||||||
|
|
||||||
|
[mod.'charm.land/glamour/v2']
|
||||||
|
version = 'v2.0.0'
|
||||||
|
hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4='
|
||||||
|
|
||||||
|
[mod.'charm.land/lipgloss/v2']
|
||||||
|
version = 'v2.0.3'
|
||||||
|
hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk='
|
||||||
|
|
||||||
|
[mod.'github.com/alecthomas/chroma/v2']
|
||||||
|
version = 'v2.24.1'
|
||||||
|
hash = 'sha256-DufsljWRKireFuLFcnPozuF0N3UoRYGlEfNFMD+z0ng='
|
||||||
|
|
||||||
|
[mod.'github.com/andybalholm/brotli']
|
||||||
|
version = 'v1.0.4'
|
||||||
|
hash = 'sha256-gAnPRdGP4yna4hiRIEDyBtDOVJqd7RU27wlPu96Rdf8='
|
||||||
|
|
||||||
|
[mod.'github.com/anotherhadi/ilovetui']
|
||||||
|
version = 'v0.1.6'
|
||||||
|
hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4='
|
||||||
|
|
||||||
|
[mod.'github.com/atotto/clipboard']
|
||||||
|
version = 'v0.1.4'
|
||||||
|
hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk='
|
||||||
|
|
||||||
|
[mod.'github.com/aymerick/douceur']
|
||||||
|
version = 'v0.2.0'
|
||||||
|
hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/colorprofile']
|
||||||
|
version = 'v0.4.3'
|
||||||
|
hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/ultraviolet']
|
||||||
|
version = 'v0.0.0-20260511121909-c840852527f3'
|
||||||
|
hash = 'sha256-oWEEaaetCmXvflD03MWy8qD105Kd62iI62+lxofE9Ns='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/ansi']
|
||||||
|
version = 'v0.11.7'
|
||||||
|
hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/exp/slice']
|
||||||
|
version = 'v0.0.0-20260517005351-920740d613be'
|
||||||
|
hash = 'sha256-bMsbEzP1gHA2OJx4zMYZUI3UFhcTG+mcFm8rRY+Khh8='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/term']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/termios']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds='
|
||||||
|
|
||||||
|
[mod.'github.com/charmbracelet/x/windows']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/displaywidth']
|
||||||
|
version = 'v0.11.0'
|
||||||
|
hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo='
|
||||||
|
|
||||||
|
[mod.'github.com/clipperhouse/uax29/v2']
|
||||||
|
version = 'v2.7.0'
|
||||||
|
hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE='
|
||||||
|
|
||||||
|
[mod.'github.com/dlclark/regexp2']
|
||||||
|
version = 'v1.12.0'
|
||||||
|
hash = 'sha256-PVX2rDCkiG0vyA1CbDi3bzLeZ2T8hcqJv3pZ5YwGzMI='
|
||||||
|
|
||||||
|
[mod.'github.com/dustin/go-humanize']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc='
|
||||||
|
|
||||||
|
[mod.'github.com/fsnotify/fsnotify']
|
||||||
|
version = 'v1.10.1'
|
||||||
|
hash = 'sha256-6LBLgsh4nKkMpgRKVsYFEaGDSU1fncBcWVSjKBdfgjU='
|
||||||
|
|
||||||
|
[mod.'github.com/go-viper/mapstructure/v2']
|
||||||
|
version = 'v2.5.0'
|
||||||
|
hash = 'sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U='
|
||||||
|
|
||||||
|
[mod.'github.com/golang/groupcache']
|
||||||
|
version = 'v0.0.0-20210331224755-41bb18bfe9da'
|
||||||
|
hash = 'sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0='
|
||||||
|
|
||||||
|
[mod.'github.com/google/uuid']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/css']
|
||||||
|
version = 'v1.0.1'
|
||||||
|
hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A='
|
||||||
|
|
||||||
|
[mod.'github.com/gorilla/websocket']
|
||||||
|
version = 'v1.5.0'
|
||||||
|
hash = 'sha256-EYVgkSEMo4HaVrsWKqnsYRp8SSS8gNf7t+Elva02Ofc='
|
||||||
|
|
||||||
|
[mod.'github.com/inconshreveable/mousetrap']
|
||||||
|
version = 'v1.1.0'
|
||||||
|
hash = 'sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE='
|
||||||
|
|
||||||
|
[mod.'github.com/klauspost/compress']
|
||||||
|
version = 'v1.17.8'
|
||||||
|
hash = 'sha256-8rgCCfHX29le8m6fyVn6gwFde5TPUHjwQqZqv9JIubs='
|
||||||
|
|
||||||
|
[mod.'github.com/lqqyt2423/go-mitmproxy']
|
||||||
|
version = 'v1.8.11'
|
||||||
|
hash = 'sha256-PoCL6TOt99JsenH4XFx+7MqIzzYnnLHYwUpEQ7kxxyg='
|
||||||
|
|
||||||
|
[mod.'github.com/lucasb-eyer/go-colorful']
|
||||||
|
version = 'v1.4.0'
|
||||||
|
hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-isatty']
|
||||||
|
version = 'v0.0.22'
|
||||||
|
hash = 'sha256-6O/0jc33pKUzlzUGpH8Ekk54XgJvx6Qe7kJtbcNJAV4='
|
||||||
|
|
||||||
|
[mod.'github.com/mattn/go-runewidth']
|
||||||
|
version = 'v0.0.23'
|
||||||
|
hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA='
|
||||||
|
|
||||||
|
[mod.'github.com/microcosm-cc/bluemonday']
|
||||||
|
version = 'v1.0.27'
|
||||||
|
hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es='
|
||||||
|
|
||||||
|
[mod.'github.com/muesli/cancelreader']
|
||||||
|
version = 'v0.2.2'
|
||||||
|
hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ='
|
||||||
|
|
||||||
|
[mod.'github.com/ncruces/go-strftime']
|
||||||
|
version = 'v1.0.0'
|
||||||
|
hash = 'sha256-GYIwYDONuv/yTE0AEugCHQbtV3oiBaco93xUNYFcVBQ='
|
||||||
|
|
||||||
|
[mod.'github.com/pelletier/go-toml/v2']
|
||||||
|
version = 'v2.3.1'
|
||||||
|
hash = 'sha256-5H8+UOtPOs+Yc+8oVT/3bugCCdbq3jFMH6eOW8dadyg='
|
||||||
|
|
||||||
|
[mod.'github.com/remyoudompheng/bigfft']
|
||||||
|
version = 'v0.0.0-20230129092748-24d4a6f8daec'
|
||||||
|
hash = 'sha256-vYmpyCE37eBYP/navhaLV4oX4/nu0Z/StAocLIFqrmM='
|
||||||
|
|
||||||
|
[mod.'github.com/rivo/uniseg']
|
||||||
|
version = 'v0.4.7'
|
||||||
|
hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo='
|
||||||
|
|
||||||
|
[mod.'github.com/sagikazarmark/locafero']
|
||||||
|
version = 'v0.12.0'
|
||||||
|
hash = 'sha256-EXk9S5Z5sYyApAzCgHIugsGMbt/pHWRfHYFZH5D+5Ws='
|
||||||
|
|
||||||
|
[mod.'github.com/sahilm/fuzzy']
|
||||||
|
version = 'v0.1.1'
|
||||||
|
hash = 'sha256-f2VsDI6G+V2w31tSDzbZPi9EI2E7jRV6Aq8yeOorSZY='
|
||||||
|
|
||||||
|
[mod.'github.com/satori/go.uuid']
|
||||||
|
version = 'v1.2.0'
|
||||||
|
hash = 'sha256-y/lSGbnZa7mYJCs30a3LTyjfCFQSaYp8GbVR8dwtmsg='
|
||||||
|
|
||||||
|
[mod.'github.com/sirupsen/logrus']
|
||||||
|
version = 'v1.9.4'
|
||||||
|
hash = 'sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/afero']
|
||||||
|
version = 'v1.15.0'
|
||||||
|
hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cast']
|
||||||
|
version = 'v1.10.0'
|
||||||
|
hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/cobra']
|
||||||
|
version = 'v1.10.2'
|
||||||
|
hash = 'sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/pflag']
|
||||||
|
version = 'v1.0.10'
|
||||||
|
hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU='
|
||||||
|
|
||||||
|
[mod.'github.com/spf13/viper']
|
||||||
|
version = 'v1.21.0'
|
||||||
|
hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI='
|
||||||
|
|
||||||
|
[mod.'github.com/subosito/gotenv']
|
||||||
|
version = 'v1.6.0'
|
||||||
|
hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A='
|
||||||
|
|
||||||
|
[mod.'github.com/xo/terminfo']
|
||||||
|
version = 'v0.0.0-20220910002029-abceb7e1c41e'
|
||||||
|
hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark']
|
||||||
|
version = 'v1.8.2'
|
||||||
|
hash = 'sha256-LoWfW1Tb6mNuMR7SoA/4SJv4pTKfsVXqeXEVm4uEQ7Q='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/goldmark-emoji']
|
||||||
|
version = 'v1.0.6'
|
||||||
|
hash = 'sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY='
|
||||||
|
|
||||||
|
[mod.'github.com/yuin/gopher-lua']
|
||||||
|
version = 'v1.1.2'
|
||||||
|
hash = 'sha256-2YMCxv7RO3uqq6OTjvE0kR9nTt+n2cF+MrLtGEW68po='
|
||||||
|
|
||||||
|
[mod.'go.uber.org/atomic']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY='
|
||||||
|
|
||||||
|
[mod.'go.yaml.in/yaml/v3']
|
||||||
|
version = 'v3.0.4'
|
||||||
|
hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/net']
|
||||||
|
version = 'v0.55.0'
|
||||||
|
hash = 'sha256-Phi2mSmBGOJcvqPPAit3uqF3UP8SKRI9dHj6yTM3s5s='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sync']
|
||||||
|
version = 'v0.20.0'
|
||||||
|
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/sys']
|
||||||
|
version = 'v0.45.0'
|
||||||
|
hash = 'sha256-hkBoNazrDA67ER6sWhb+EKxx9nJ24+nz3zGy+zT5Hvw='
|
||||||
|
|
||||||
|
[mod.'golang.org/x/text']
|
||||||
|
version = 'v0.37.0'
|
||||||
|
hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E='
|
||||||
|
|
||||||
|
[mod.'gopkg.in/yaml.v3']
|
||||||
|
version = 'v3.0.1'
|
||||||
|
hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU='
|
||||||
|
|
||||||
|
[mod.'modernc.org/libc']
|
||||||
|
version = 'v1.72.3'
|
||||||
|
hash = 'sha256-eOCoqSnX/VzpG63nh1j3JpXRndnZZcn2cDTeutexXAI='
|
||||||
|
|
||||||
|
[mod.'modernc.org/mathutil']
|
||||||
|
version = 'v1.7.1'
|
||||||
|
hash = 'sha256-COZ5rF2GhQVR1r6a0DanJ8qwQ94JSKdQxTMWrDzE0Cc='
|
||||||
|
|
||||||
|
[mod.'modernc.org/memory']
|
||||||
|
version = 'v1.11.0'
|
||||||
|
hash = 'sha256-MkybF8vvrxXS5j7O8w3skwTo0aMo1yjWS0K440rYcHM='
|
||||||
|
|
||||||
|
[mod.'modernc.org/sqlite']
|
||||||
|
version = 'v1.50.1'
|
||||||
|
hash = 'sha256-JbGP3eerH/6mgzI0aq9SpLLwe6ld6PV/zJReG8mLQN0='
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
buildGoApplication,
|
||||||
|
}: let
|
||||||
|
browser = import ./browser.nix {inherit pkgs;};
|
||||||
|
pname = "spilltea";
|
||||||
|
version = "0.0.8";
|
||||||
|
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||||
|
pkg = buildGoApplication {
|
||||||
|
inherit pname version ldflags;
|
||||||
|
src = ../.;
|
||||||
|
modules = ./gomod2nix.toml;
|
||||||
|
nativeBuildInputs = [pkgs.installShellFiles];
|
||||||
|
env.GOTOOLCHAIN = "local";
|
||||||
|
postInstall = ''
|
||||||
|
installShellCompletion --cmd spilltea \
|
||||||
|
--bash <($out/bin/spilltea completion bash) \
|
||||||
|
--zsh <($out/bin/spilltea completion zsh) \
|
||||||
|
--fish <($out/bin/spilltea completion fish)
|
||||||
|
'';
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.";
|
||||||
|
homepage = "https://github.com/anotherhadi/spilltea";
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
"${pname}" = pkg;
|
||||||
|
default = pkg;
|
||||||
|
browser = browser;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
gitHooksLib,
|
||||||
|
gomod2nixPkgs,
|
||||||
|
}: let
|
||||||
|
hooks = gitHooksLib.run {
|
||||||
|
src = ../.;
|
||||||
|
hooks = {
|
||||||
|
gofmt.enable = true;
|
||||||
|
govet.enable = true;
|
||||||
|
stylua.enable = true;
|
||||||
|
|
||||||
|
gomod2nix = {
|
||||||
|
enable = true;
|
||||||
|
name = "gomod2nix";
|
||||||
|
entry = "gomod2nix --outdir ./nix";
|
||||||
|
language = "system";
|
||||||
|
files = "go\\.(mod|sum)$";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec-basics = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec-basics";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py docs/basics.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
inject-exec = {
|
||||||
|
enable = true;
|
||||||
|
name = "inject-exec";
|
||||||
|
entry = "python3 .github/scripts/inject-exec.py README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
doctoc = {
|
||||||
|
enable = true;
|
||||||
|
name = "doctoc";
|
||||||
|
entry = "doctoc --notitle README.md";
|
||||||
|
language = "system";
|
||||||
|
files = "(README\\.md|docs/basics\\.md|cmd/)";
|
||||||
|
pass_filenames = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = with pkgs;
|
||||||
|
[
|
||||||
|
go
|
||||||
|
gosec
|
||||||
|
govulncheck
|
||||||
|
python3
|
||||||
|
doctoc
|
||||||
|
stylua
|
||||||
|
trufflehog
|
||||||
|
gomod2nixPkgs.gomod2nix
|
||||||
|
]
|
||||||
|
++ hooks.enabledPackages;
|
||||||
|
|
||||||
|
shellHook = hooks.shellHook;
|
||||||
|
}
|
||||||
@@ -4,21 +4,27 @@ Plugin = {
|
|||||||
Inject custom headers into every intercepted request.
|
Inject custom headers into every intercepted request.
|
||||||
|
|
||||||
**Config**:
|
**Config**:
|
||||||
- one 'Header-Name: value' per line.
|
```yaml
|
||||||
|
headers:
|
||||||
|
- "X-My-Header: myvalue"
|
||||||
|
```
|
||||||
]],
|
]],
|
||||||
on_request = { sync = true },
|
on_request = { sync = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local headers = {}
|
local headers = {}
|
||||||
|
|
||||||
function on_config(config_text)
|
function on_config()
|
||||||
headers = {}
|
headers = {}
|
||||||
for line in config_text:gmatch("[^\n]+") do
|
local cfg = get_config()
|
||||||
|
if cfg and cfg.headers then
|
||||||
|
for _, line in ipairs(cfg.headers) 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
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function on_request(req)
|
function on_request(req)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user