29 Commits

Author SHA1 Message Date
Hadi 2e61ffa125 Add ilovetui instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 20:01:41 +02:00
Hadi 9ac6040629 Migrate components to ilovetui
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 16:38:09 +02:00
Hadi c7523e69e7 add vim/neovim integration instructions
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 16:15:07 +02:00
Hadi aa458c142f use ilovetui colors & styles 2026-05-26 16:12:10 +02:00
Hadi 0b395e018a remove unused script
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-26 00:54:29 +02:00
Hadi 5841ed0a95 Allow user to "edit" response in replay: allow vim navigation & filters
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 14:41:17 +02:00
Hadi 9cabe81771 Edit descriptions & create_findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 10:28:52 +02:00
Hadi 021090f52c init send_request func for plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 10:21:37 +02:00
Hadi 0b9e1a1cf0 Change default plugin's config in global config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 09:54:57 +02:00
Hadi f60cdf2033 Delete all except flagged entries
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-21 09:34:52 +02:00
Hadi 6af1388652 init match & replace plugin
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 20:25:01 +02:00
Hadi a708830309 give the set_path et set_url fonction to plugins
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 19:31:44 +02:00
Hadi 44b3c67a37 +stylua
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 19:31:31 +02:00
Hadi 6e673f5c11 v0.0.6
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:12:25 +02:00
Hadi 2c63cdbeff Edit plugins id & docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 15:09:09 +02:00
Hadi 0e982c6ade add pages "update" label
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:49:01 +02:00
Hadi 04ba32cbd5 order by asc
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:48:34 +02:00
Hadi f7e9da94cc Add scroll icon on viewports
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 14:36:27 +02:00
Hadi 9253d85c81 init faq.md
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:42:02 +02:00
Hadi 1bb547870e add "temp" for temporary projects
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 13:39:11 +02:00
Hadi 4251e4fb2a plugin's config is now in yaml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 11:43:26 +02:00
Hadi b547a79d6e add trufflehog to dev deps
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:53:42 +02:00
Hadi fe58468abf add direnv
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:45:38 +02:00
Hadi 3542098905 add gomod2nix.toml
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:35:48 +02:00
Hadi f78b3f7174 Move pre-commit hooks to nix
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:33:16 +02:00
Hadi 722021ba02 merge plugins & docs embed
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:47 +02:00
Hadi e18f660e83 move the goreleaser config
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:27:35 +02:00
Hadi 67fe8eb911 fix: log silent errors, harden proxy auth, optimize db and render pipeline
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-20 10:19:37 +02:00
Hadi af872afbe8 gofmt
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-19 23:09:00 +02:00
80 changed files with 1918 additions and 1406 deletions
+1
View File
@@ -0,0 +1 @@
use flake
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
CURRENT_HASH=$(grep -oP '(?<=vendorHash = ")[^"]+' flake.nix)
go mod vendor
COMPUTED_HASH=$(nix hash path vendor/)
rm -rf vendor/
if [ "$CURRENT_HASH" = "$COMPUTED_HASH" ]; then
echo "vendorHash is up to date"
exit 0
fi
echo "Updating vendorHash in flake.nix..."
python3 -c "
import sys
with open('flake.nix', 'r') as f:
content = f.read()
content = content.replace('$CURRENT_HASH', '$COMPUTED_HASH')
with open('flake.nix', 'w') as f:
f.write(content)
"
echo " Old: $CURRENT_HASH"
echo " New: $COMPUTED_HASH"
+1 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
.claude/ .claude/
CLAUDE.md CLAUDE.md
result/ result/
.pre-commit-config.yaml
+12
View File
@@ -25,6 +25,7 @@
- [Configuration](#configuration) - [Configuration](#configuration)
- [CLI Flags](#cli-flags) - [CLI Flags](#cli-flags)
- [Plugin System](#plugin-system) - [Plugin System](#plugin-system)
- [Vim / Neovim Integration](#vim--neovim-integration)
- [Deployment](#deployment) - [Deployment](#deployment)
- [Tech Stack](#tech-stack) - [Tech Stack](#tech-stack)
@@ -115,6 +116,8 @@ On startup, you choose:
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.
## CLI Flags ## CLI Flags
``` ```
@@ -137,6 +140,15 @@ Usage: spilltea [flags]
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. 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/). 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
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component. spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
+3 -2
View File
@@ -44,7 +44,8 @@ func main() {
) )
flag.CommandLine.SetOutput(os.Stdout) flag.CommandLine.SetOutput(os.Stdout)
flag.Usage = func() { flag.Usage = func() {
fmt.Println("Usage: spilltea [flags]\n") fmt.Println("Usage: spilltea [flags]")
fmt.Println("")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -121,7 +122,7 @@ func main() {
config.Global.App.UpstreamProxy = *flagUpstreamProxy config.Global.App.UpstreamProxy = *flagUpstreamProxy
} }
style.Init(config.Global) style.Init()
icons.Init(config.Global) icons.Init(config.Global)
keys.Init(config.Global) keys.Init(config.Global)
-6
View File
@@ -1,6 +0,0 @@
package spilltea
import "embed"
//go:embed docs
var DocsFS embed.FS
+2
View File
@@ -13,6 +13,8 @@ On startup, you choose:
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.
## CLI Flags ## CLI Flags
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' --> <!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
+27
View File
@@ -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.
+74 -48
View File
@@ -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/).
@@ -32,13 +34,13 @@ Plugin = {
### Hook reference ### Hook reference
| Hook | When called | Sync/async | Return value | | 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 | `false` to self-disable the plugin, otherwise 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` (sync only) | | `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` (sync only) | | `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) -- sync only | | `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
@@ -120,17 +122,77 @@ if err then
else else
log("output: " .. out) log("output: " .. out)
end 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.
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
@@ -139,49 +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_start`:**
| Return value | Effect |
| ------------ | -------------------------------------------------------------------------------------------- |
| `false` | The plugin is disabled immediately and the state is persisted (equivalent to toggling it off). |
| anything else | Ignored. |
This is useful for prerequisite checks (binary not found, config invalid, etc.) so the plugin does not silently run in a broken state:
```lua
function on_start()
local h = io.popen("command -v mytool 2>/dev/null")
local result = h and h:read("*a") or ""
if h then h:close() end
if result:match("^%s*$") then
notif("MyPlugin", "mytool not found, plugin disabled", "error")
return false
end
end
```
**`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 = {
Generated
+115
View File
@@ -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",
+21 -35
View File
@@ -1,55 +1,41 @@
{ {
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.5";
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 = ./.;
outputs = ["out"];
vendorHash = "sha256-1iPwFsyzdonak9EWMRnudwcCQZfI+Uvre38+puG4s0s=";
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;
}); });
devShells = forAllSystems (system: pkgs: { devShells = forAllSystems (system: pkgs: {
default = pkgs.mkShell { default = import ./nix/shell.nix {
packages = with pkgs; [ inherit pkgs;
go gitHooksLib = git-hooks.lib.${system};
python3 gomod2nixPkgs = gomod2nix.legacyPackages.${system};
lefthook
doctoc
];
shellHook = ''
lefthook install
'';
}; };
}); });
}; };
+3 -2
View File
@@ -7,7 +7,10 @@ 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.9.4 github.com/sirupsen/logrus v1.9.4
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
@@ -20,7 +23,6 @@ require (
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
@@ -39,7 +41,6 @@ require (
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/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.22 // 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
+2
View File
@@ -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=
-20
View File
@@ -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"`
}
+9 -2
View File
@@ -31,7 +31,6 @@ type Config struct {
} `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"`
@@ -41,6 +40,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 {
@@ -53,6 +53,13 @@ type Config struct {
} `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
@@ -82,7 +89,7 @@ func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err) return fmt.Errorf("create config dir: %w", err)
} }
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil { if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
return fmt.Errorf("write config: %w", err) return fmt.Errorf("write config: %w", err)
} }
return nil return nil
+2 -18
View File
@@ -5,13 +5,14 @@ app:
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 http://user:pass@host:8888
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled) proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB) 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) external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
default_capture_response: false default_capture_response: false
queue_size: 64 # 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)(\?.*)?$'
@@ -26,23 +27,6 @@ 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:
+13 -5
View File
@@ -9,6 +9,7 @@ import (
type DB struct { type DB struct {
conn *sql.DB conn *sql.DB
roConn *sql.DB
path string path string
dedupMu sync.Mutex dedupMu sync.Mutex
} }
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
conn.Close() conn.Close()
return nil, err return nil, err
} }
roConn, err := sql.Open("sqlite", path)
if err != nil {
conn.Close()
return nil, err
}
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
conn.Close()
roConn.Close()
return nil, err
}
d.roConn = roConn
return d, nil return d, nil
} }
@@ -60,11 +72,6 @@ CREATE TABLE IF NOT EXISTS replay_entries (
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL error_msg TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
config_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS findings ( 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,
@@ -94,6 +101,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()
} }
+24 -12
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"strings" "strings"
"time" "time"
) )
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
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
} }
@@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
// QueryEntries runs a WHERE expression supplied by the user against the entries // QueryEntries runs a WHERE expression supplied by the user against the entries
// table (e.g. "status_code = 404" or "host LIKE '%example.com%'"). // table (e.g. "status_code = 404" or "host LIKE '%example.com%'").
// It opens a dedicated read-only connection so that any DML or DDL in the // Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
// user-supplied expression is rejected by SQLite before it can execute. // DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
func (d *DB) QueryEntries(where string) ([]Entry, error) { func (d *DB) QueryEntries(where string) ([]Entry, error) {
roConn, err := sql.Open("sqlite", d.path)
if err != nil {
return nil, err
}
defer roConn.Close()
if _, err := roConn.Exec("PRAGMA query_only=ON"); err != nil {
return nil, err
}
q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where) q := "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw, flagged FROM entries WHERE " + strings.TrimSpace(where)
rows, err := roConn.Query(q) rows, err := d.roConn.Query(q)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -147,3 +144,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
}
+1 -1
View File
@@ -35,7 +35,7 @@ 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, 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
-35
View File
@@ -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()
}
+6 -2
View File
@@ -58,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
+143
View File
@@ -3,14 +3,20 @@ package plugins
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"io"
"log" "log"
"net/http"
"net/url"
"os/exec" "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 {
@@ -175,6 +181,99 @@ 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 { L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
cmd := L.CheckString(1) cmd := L.CheckString(1)
input := L.OptString(2, "") input := L.OptString(2, "")
@@ -201,6 +300,35 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
})) }))
} }
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 {
v := t.RawGetString(key) v := t.RawGetString(key)
if s, ok := v.(lua.LString); ok { if s, ok := v.(lua.LString); ok {
@@ -241,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] set_url: %v", err)
return 0
}
r.URL = parsed
return 0
}))
return t return t
} }
+65 -49
View File
@@ -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,9 +79,23 @@ 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)
@@ -89,6 +109,7 @@ func (m *Manager) LoadFromDir(dir string) error {
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),
@@ -109,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 {
@@ -131,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}
@@ -146,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}
} }
} }
@@ -163,11 +178,11 @@ func (m *Manager) GetPlugins() []*Plugin {
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
} }
@@ -179,10 +194,11 @@ 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 { if !enabled {
return return
@@ -194,8 +210,10 @@ func (m *Manager) TogglePlugin(name string) {
disableIfFalse := func(p *Plugin, ret lua.LValue) { disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.pluginsFile != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText) if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
} }
} }
} }
@@ -222,11 +240,11 @@ func (m *Manager) TogglePlugin(name string) {
} }
} }
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
} }
@@ -237,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
@@ -281,8 +295,10 @@ func (m *Manager) RunOnStart() {
disableIfFalse := func(p *Plugin, ret lua.LValue) { disableIfFalse := func(p *Plugin, ret lua.LValue) {
if ret == lua.LFalse { if ret == lua.LFalse {
p.Enabled = false p.Enabled = false
if m.db != nil { if m.pluginsFile != nil {
_ = m.db.SavePluginState(p.Name, false, p.ConfigText) if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
log.Printf("plugin %s: save state: %v", p.ID, err)
}
} }
} }
} }
+103
View File
@@ -0,0 +1,103 @@
package plugins
import (
"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)
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
_ = pf.save()
}
}
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()
}
+3
View File
@@ -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,
+4 -2
View File
@@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"crypto/subtle"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
@@ -46,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)
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
wantUser, wantPass := parts[0], parts[1] wantUser, wantPass := parts[0], parts[1]
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) { p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization")) user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
if !ok || user != wantUser || pass != wantPass { 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"`) res.Header().Set("Proxy-Authenticate", `Basic realm="spilltea"`)
return false, fmt.Errorf("invalid credentials") return false, fmt.Errorf("invalid credentials")
} }
-43
View File
@@ -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)
}
+10 -61
View File
@@ -1,64 +1,23 @@
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
}
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)
} }
} }
@@ -66,22 +25,12 @@ 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
}
-236
View File
@@ -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 -25
View File
@@ -3,12 +3,13 @@ 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 {
@@ -18,6 +19,7 @@ func Paint(c color.Color, s string) string {
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)
@@ -74,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)
} }
@@ -94,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
} }
@@ -133,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'):
@@ -146,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)
@@ -264,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
} }
@@ -277,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
@@ -288,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] != '>' {
@@ -298,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
@@ -312,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
View File
@@ -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
} }
} }
+14
View File
@@ -106,6 +106,20 @@ func New(broker *intercept.Broker, name, path string) Model {
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)
if err := mgr.LoadFromDir(pluginsDir); err != nil { if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err) log.Printf("plugins: %v", err)
+4
View File
@@ -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,
+13 -10
View File
@@ -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,22 +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
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
for i, entry := range sidebarEntries { for i, entry := range sidebarEntries {
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 {
@@ -91,13 +94,13 @@ func (m *Model) renderSidebar() string {
} }
parts := []string{ parts := []string{
title, title,
lipgloss.NewStyle().Width(inner).Foreground(s.Subtle).Padding(0, 1).Render(name), lipgloss.NewStyle().Width(inner).Foreground(ilovetui.S.Subtle).Padding(0, 1).Render(name),
} }
parts = append(parts, parts = append(parts,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider), lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider),
items.String(), items.String(),
) )
body := lipgloss.JoinVertical(lipgloss.Left, parts...) 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)
} }
+10
View File
@@ -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)
@@ -129,6 +132,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:
@@ -258,8 +264,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()
}
} }
} }
} }
+2 -2
View File
@@ -3,7 +3,7 @@ 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) View() tea.View { func (m Model) View() tea.View {
@@ -52,5 +52,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")
} }
+8 -11
View File
@@ -6,7 +6,7 @@ import (
"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 ( const (
@@ -14,7 +14,6 @@ const (
popupH = 20 popupH = 20
) )
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
Scheme string Scheme string
@@ -48,20 +47,18 @@ 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, popupW, 8) l := list.New(allItems, delegate, popupW, 8)
l.SetShowTitle(false) l.SetShowTitle(false)
@@ -126,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 -6
View File
@@ -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, m.popupInnerWidth()+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)
} }
+8 -11
View File
@@ -4,7 +4,7 @@ import (
"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 ( const (
@@ -12,7 +12,6 @@ const (
popupH = 20 popupH = 20
) )
type OpenMsg struct { type OpenMsg struct {
RawRequest string RawRequest string
Scheme string Scheme string
@@ -49,20 +48,18 @@ 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, popupW, 8) l := list.New(allFormats, delegate, popupW, 8)
l.SetShowTitle(false) l.SetShowTitle(false)
@@ -117,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
} }
+5 -9
View File
@@ -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, m.popupInnerWidth()+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().
+10 -12
View File
@@ -8,6 +8,7 @@ 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" "github.com/anotherhadi/spilltea/internal/util"
@@ -85,10 +86,9 @@ func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
segs[lo], segs[hi] = segs[hi], segs[lo] segs[lo], segs[hi] = segs[hi], segs[lo]
} }
s := style.S boldErr := lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true)
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true) boldOk := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true)
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true) dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
dim := lipgloss.NewStyle().Foreground(s.Subtle)
var lb, rb strings.Builder var lb, rb strings.Builder
for _, seg := range segs { for _, seg := range segs {
@@ -212,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(),
} }
} }
@@ -255,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)
@@ -296,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(util.CenterLines("<(^_^)>", "send two entries here to compare")), ilovetui.S.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")),
) )
m.leftViewport.SetContent(placeholder) m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("") m.rightViewport.SetContent("")
@@ -314,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(util.CenterLines("(・3・)", "waiting for second entry…")), ilovetui.S.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")),
) )
m.rightViewport.SetContent(placeholder) m.rightViewport.SetContent(placeholder)
return return
+11 -14
View File
@@ -5,6 +5,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/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -26,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
@@ -46,20 +45,20 @@ func (m *Model) renderPanels(panelH int) string {
rightTitle = ansi.Truncate(rightTitle, maxW, "…") rightTitle = ansi.Truncate(rightTitle, maxW, "…")
} }
leftBorder := s.Panel leftBorder := ilovetui.S.Panel
rightBorder := s.Panel rightBorder := ilovetui.S.Panel
switch m.focus { switch m.focus {
case bothSlots: case bothSlots:
leftBorder = s.PanelFocused leftBorder = ilovetui.S.PanelFocused
rightBorder = s.PanelFocused rightBorder = ilovetui.S.PanelFocused
case leftSlot: case leftSlot:
leftBorder = s.PanelFocused leftBorder = ilovetui.S.PanelFocused
case rightSlot: case rightSlot:
rightBorder = s.PanelFocused rightBorder = ilovetui.S.PanelFocused
} }
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH) left := ilovetui.RenderWithTitle(leftBorder, leftTitle, ilovetui.ViewportView(&m.leftViewport), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, 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)
} }
@@ -69,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:
@@ -85,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:
+5 -5
View File
@@ -14,8 +14,8 @@ 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"
) )
func readDoc(name string) string { func readDoc(name string) string {
@@ -58,12 +58,12 @@ func New() Model {
ti := textinput.New() ti := textinput.New()
ti.Prompt = "/" ti.Prompt = "/"
s := ti.Styles() s := ti.Styles()
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary) s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
ti.SetStyles(s) ti.SetStyles(s)
return Model{ return Model{
viewport: viewport.New(), viewport: viewport.New(),
help: style.NewHelp(), help: ilovetui.NewHelp(),
searchInput: ti, searchInput: ti,
} }
} }
@@ -182,8 +182,8 @@ func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int)
return ansiLine return ansiLine
} }
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg)) normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg))
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text)) currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text))
type injection struct { type injection struct {
visPos int visPos int
+4 -4
View File
@@ -9,15 +9,15 @@ 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"
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" "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)
} }
@@ -31,7 +31,7 @@ func (e Model) View() tea.View {
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches)) countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
} }
count := lipgloss.NewStyle().Padding(0, 1). count := lipgloss.NewStyle().Padding(0, 1).
Foreground(style.S.MutedFg). Foreground(ilovetui.S.Muted).
Render(countText) Render(countText)
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count) statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
} }
@@ -61,7 +61,7 @@ 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),
) )
+15 -11
View File
@@ -11,10 +11,9 @@ 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" "github.com/anotherhadi/spilltea/internal/util"
) )
@@ -22,6 +21,8 @@ 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
@@ -37,15 +38,18 @@ type Model struct {
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 { func (m *Model) CurrentMarkdown() string {
if len(m.findings) == 0 { if len(m.findings) == 0 {
return "" return ""
@@ -71,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
} }
@@ -84,7 +88,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)
@@ -143,7 +147,7 @@ func (m *Model) refreshBodyScroll(reset bool) {
func (m *Model) renderMarkdownCached(src string, width int) string { func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" { if src == "" {
return style.S.Faint.Render(util.CenterLines("(ㆆ _ ㆆ)", "no 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 {
@@ -159,7 +163,7 @@ func (m *Model) renderMarkdownCached(src string, width int) string {
// Rebuild renderer if width changed or not yet built. // Rebuild renderer if width changed or not yet built.
if m.renderer == nil || m.rendererWidth != width { 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 {
+3
View File
@@ -21,6 +21,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
prevID = m.findings[m.cursor].ID 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)
} }
+15 -23
View File
@@ -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,44 +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 var dots string
if len(m.findings) > 0 { if len(m.findings) > 0 {
dots = s.Faint.Render(m.pager.View()) 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(util.CenterLines("(҂◡_◡) ᕤ", "no findings")), ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "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] {
@@ -82,18 +74,18 @@ func (m *Model) renderList() string {
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)
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), 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),
) )
@@ -104,9 +96,9 @@ func (m *Model) renderList() string {
" ", " ",
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))
+8 -8
View File
@@ -7,9 +7,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/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" "github.com/anotherhadi/spilltea/internal/util"
) )
@@ -44,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,
} }
} }
@@ -124,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
} }
@@ -142,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)
+13 -2
View File
@@ -7,6 +7,7 @@ import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "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"
@@ -249,11 +250,21 @@ 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 {
if !e.Flagged {
hasUnflagged = true
break
}
}
for _, e := range m.entries {
if hasUnflagged && e.Flagged {
continue
}
m.database.DeleteEntry(e.ID) m.database.DeleteEntry(e.ID)
} }
} else { } else {
m.database.DeleteAllEntries() m.database.DeleteAllExceptFlagged()
} }
} }
return m, m.clearSearch() return m, m.clearSearch()
@@ -335,7 +346,7 @@ func (m *Model) refreshBody() {
} }
if raw == "" { if raw == "" {
w, h := m.bodyViewport.Width(), m.bodyViewport.Height() w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored")))) m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
return return
} }
m.bodyViewport.SetContent(style.HighlightHTTP(raw)) m.bodyViewport.SetContent(style.HighlightHTTP(raw))
+24 -33
View File
@@ -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/keys" "github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
@@ -17,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),
@@ -28,60 +29,56 @@ 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 var dots string
if len(m.entries) > 0 { if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View()) 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 {
@@ -92,24 +89,18 @@ 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,
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)
@@ -121,7 +112,7 @@ 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(s.Primary) flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string var line string
if selected { if selected {
@@ -134,13 +125,13 @@ func (m *Model) renderList() string {
} }
} }
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(">"),
bg.Foreground(s.Primary).Width(2).Render(flagStr), bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
s.Method(e.Method).Background(selBg).Render(e.Method), 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),
) )
@@ -155,14 +146,14 @@ func (m *Model) renderList() string {
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr), flagSt.Width(2).Render(flagStr),
s.Method(e.Method).Render(e.Method), 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")
+10 -12
View File
@@ -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),
} }
} }
@@ -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)
} }
+2 -2
View File
@@ -138,14 +138,14 @@ 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, 0o755); err != nil {
return nil, err return nil, err
+7 -9
View File
@@ -5,29 +5,28 @@ 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/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 {
@@ -50,7 +49,6 @@ func (m Model) View() tea.View {
} }
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 +61,15 @@ 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") hint := ilovetui.S.Faint.Render("[enter] confirm [esc] cancel")
var sb strings.Builder var sb strings.Builder
sb.WriteString(center(iw, panel)) sb.WriteString(center(iw, panel))
+4 -3
View File
@@ -7,6 +7,7 @@ import (
"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" "github.com/anotherhadi/spilltea/internal/util"
@@ -157,20 +158,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
} }
-4
View File
@@ -1,15 +1,11 @@
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/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 { func iconBinding(b key.Binding, icon string) key.Binding {
+12 -8
View File
@@ -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,6 +77,9 @@ 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 { func (m Model) IsResponseFocused() bool {
+1
View File
@@ -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()
+31 -50
View File
@@ -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)
var listRow string var listRow string
if m.captureResponse { if m.captureResponse {
@@ -38,17 +39,15 @@ 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
} }
var dots string var dots string
if len(m.queue) > 0 { if len(m.queue) > 0 {
dots = s.Faint.Render(m.pager.View()) dots = ilovetui.S.Faint.Render(m.pager.View())
} }
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(), m.listViewport.View(),
@@ -57,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 {
@@ -105,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(util.CenterLines("(。◕‿‿◕。)", "waiting for a request"))) return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(。◕‿‿◕。)", "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] {
@@ -127,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
@@ -140,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")
@@ -161,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(util.CenterLines("(҂◡_◡)", "no response yet"))) return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡)", "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] {
@@ -189,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)
@@ -204,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),
@@ -213,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")
+9 -9
View File
@@ -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)
+4 -4
View File
@@ -59,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
} }
@@ -107,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
} }
+30 -41
View File
@@ -7,19 +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" "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(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(._.)~*.'", "no plugins loaded")))) return tea.NewView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(._.)~*.'", "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),
@@ -30,36 +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 var dots string
if len(m.filtered) > 0 { if len(m.filtered) > 0 {
dots = s.Faint.Render(m.pager.View()) 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(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 {
@@ -69,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 {
@@ -100,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 {
@@ -114,23 +111,21 @@ 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(), width: m.width}))) 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(), width: m.width})) 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 := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins") msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
if m.filter != "" { if m.filter != "" {
@@ -139,27 +134,21 @@ 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,
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 "
} }
@@ -172,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),
) )
@@ -184,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")
+10 -10
View File
@@ -9,9 +9,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/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 {
@@ -61,15 +61,15 @@ type Model struct {
} }
func New() Model { func New() Model {
ta := style.NewTextarea(false) ta := ilovetui.NewTextarea(false)
ta.Blur() ta.Blur()
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(), pager: ilovetui.NewPaginator(),
help: style.NewHelp(), help: ilovetui.NewHelp(),
} }
} }
@@ -149,13 +149,13 @@ 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)
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
} }
@@ -175,7 +175,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)
+7 -3
View File
@@ -16,12 +16,13 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/config" "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"
"github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff" diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
"github.com/anotherhadi/spilltea/internal/util"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
) )
@@ -181,6 +182,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)
} }
@@ -347,11 +351,11 @@ func (m *Model) refreshBody() {
m.requestViewport.SetXOffset(0) m.requestViewport.SetXOffset(0)
if e.Sending { if e.Sending {
m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "sending...")))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("(ノ◕ヮ◕)ノ*:・゚", "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(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire")))) m.responseViewport.SetContent(lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, ilovetui.S.Faint.Render(util.CenterLines("( •_•)>⌐■", "press send to fire"))))
} }
m.responseViewport.SetYOffset(0) m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0) m.responseViewport.SetXOffset(0)
+25 -34
View File
@@ -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)
leftW, rightW := m.bodyHalfWidths() leftW, rightW := m.bodyHalfWidths()
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top, bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
@@ -33,45 +34,42 @@ 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.Panel
if !m.editing && m.focusedPanel == panelList { if !m.editing && m.focusedPanel == panelList {
panelStyle = s.PanelFocused panelStyle = ilovetui.S.PanelFocused
} }
var dots string var dots string
if len(m.entries) > 0 { if len(m.entries) > 0 {
dots = s.Faint.Render(m.pager.View()) 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(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 { if m.focusedPanel == panelRequest {
border = s.PanelFocused 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
border := s.Panel
if !m.editing && m.focusedPanel == panelResponse { if !m.editing && m.focusedPanel == panelResponse {
border = s.PanelFocused border = ilovetui.S.PanelFocused
} }
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h) return ilovetui.RenderWithTitle(border, icons.I.Response+"Response", ilovetui.ViewportView(&m.responseViewport), w, h)
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
@@ -83,24 +81,17 @@ 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(util.CenterLines("(╥﹏╥)", "send a request from History or Intercept")), ilovetui.S.Faint.Render(util.CenterLines("(╥﹏╥)", "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 + 7 + 1 + 3 + 1
@@ -115,8 +106,8 @@ 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(e.Method).Background(selBg).Render(e.Method), 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(""),
@@ -125,12 +116,12 @@ func (m *Model) renderList() string {
} else { } else {
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
s.Method(e.Method).Render(e.Method), 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")
@@ -142,11 +133,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)
} }
+22 -3
View File
@@ -1,6 +1,7 @@
package util package util
import ( import (
"log"
"os" "os"
"os/exec" "os/exec"
@@ -14,7 +15,7 @@ type EditorFinishedMsg struct {
Err error Err error
} }
func OpenExternalEditor(content string) tea.Cmd { func resolveEditor() string {
editor := config.Global.App.ExternalEditor editor := config.Global.App.ExternalEditor
if editor == "" { if editor == "" {
editor = os.Getenv("EDITOR") editor = os.Getenv("EDITOR")
@@ -22,15 +23,27 @@ func OpenExternalEditor(content string) tea.Cmd {
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 {
return nil return nil
} }
tmpPath := f.Name() tmpPath := f.Name()
_, _ = f.WriteString(content) if _, err := f.WriteString(content); err != nil {
log.Printf("editor: writing temp file: %v", err)
}
f.Close() f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg {
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}
} }
@@ -41,3 +54,9 @@ func OpenExternalEditor(content string) tea.Cmd {
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
})
}
+13
View File
@@ -3,6 +3,7 @@ package util
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
) )
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
return strings.Join(centered, "\n") 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") {
-19
View File
@@ -1,19 +0,0 @@
pre-commit:
piped: true
commands:
check-vendor-hash:
glob: "{go.mod,go.sum}"
run: .github/scripts/check-vendor-hash.sh
stage_fixed: true
inject-exec-basics:
glob: "{docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py docs/basics.md
stage_fixed: true
inject-exec:
glob: "{README.md,docs/basics.md,cmd/**}"
run: python3 .github/scripts/inject-exec.py README.md
stage_fixed: true
toc:
glob: "{README.md,docs/basics.md,cmd/**}"
run: doctoc --notitle README.md
stage_fixed: true
+246
View File
@@ -0,0 +1,246 @@
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/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/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.54.0'
hash = 'sha256-/EoIXzTQzK/yP/lxOyx0Z/bhns4FdPTIF4uyt4gIP80='
[mod.'golang.org/x/sync']
version = 'v0.20.0'
hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y='
[mod.'golang.org/x/sys']
version = 'v0.44.0'
hash = 'sha256-JDlj+PKsG6I6kjv5JyOUNreY51u5An0oZ5OZMHZSk+A='
[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='
+21
View File
@@ -0,0 +1,21 @@
{
pkgs,
buildGoApplication,
}: let
pname = "spilltea";
version = "0.0.6";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;
src = ../.;
modules = ./gomod2nix.toml;
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;
}
+64
View File
@@ -0,0 +1,64 @@
{
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
python3
doctoc
stylua
trufflehog
gomod2nixPkgs.gomod2nix
]
++ hooks.enabledPackages;
shellHook = hooks.shellHook;
}
+9 -3
View File
@@ -4,22 +4,28 @@ 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)
for _, h in ipairs(headers) do for _, h in ipairs(headers) do
+15 -11
View File
@@ -4,10 +4,13 @@ Plugin = {
Checks that the proxy's outbound IP is in an allowed list on startup. Checks that the proxy's outbound IP is in an allowed list on startup.
**Config**: **Config**:
- one IP per line ```yaml
- prefix with `!` for a blacklist entry (blocked) ips:
- prefix with `#` to comment it out (ignored) - "1.2.3.4" # whitelist entry
- if no IPs are configured, the check is skipped - "!5.6.7.8" # blacklist entry (blocked)
```
- If no IPs are configured, the check is skipped.
]], ]],
on_start = { sync = false }, on_start = { sync = false },
disable_by_default = true, disable_by_default = true,
@@ -16,13 +19,13 @@ Checks that the proxy's outbound IP is in an allowed list on startup.
local whitelist = {} local whitelist = {}
local blacklist = {} local blacklist = {}
function on_config(config_text) function on_config()
whitelist = {} whitelist, blacklist = {}, {}
blacklist = {} local cfg = get_config()
if cfg and cfg.ips then
for line in config_text:gmatch("[^\n]+") do for _, entry in ipairs(cfg.ips) do
local trimmed = line:match("^%s*(.-)%s*$") local trimmed = entry:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" then
if trimmed:sub(1, 1) == "!" then if trimmed:sub(1, 1) == "!" then
local ip = trimmed:sub(2):match("^%s*(.-)%s*$") local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
if ip ~= "" then if ip ~= "" then
@@ -34,6 +37,7 @@ function on_config(config_text)
end end
end end
end end
end
function on_start() function on_start()
if #whitelist == 0 and #blacklist == 0 then if #whitelist == 0 and #blacklist == 0 then
+125
View File
@@ -0,0 +1,125 @@
Plugin = {
name = "Match & Replace",
description = [[
Automatically find and replace content in requests and responses.
**Config**:
```yaml
rules:
- on: "request" # "request", "response", or "both" (default: "both")
url: "example%.com" # optional: Lua pattern to filter by URL
target: "body" # "body", "path", "url", or "header:Name"
find: "old_string" # Lua pattern to search
replace: "new_string" # replacement string (supports %1, %2 for captures)
```
Example (inject a debug flag in JSON bodies):
```yaml
rules:
- on: "both"
url: "api%.example%.com"
target: "body"
find: '"debug":false'
replace: '"debug":true'
```
Example (replace an Authorization header):
```yaml
rules:
- on: "request"
target: "header:Authorization"
find: "Bearer .*"
replace: "Bearer MY_NEW_TOKEN"
```
Example (rewrite a path prefix):
```yaml
rules:
- on: "request"
url: "staging%.example%.com"
target: "path"
find: "^/v1/"
replace: "/v2/"
```
]],
priority = 50,
on_request = { sync = true },
on_response = { sync = true },
}
local rules = {}
function on_config()
rules = {}
local cfg = get_config()
if not cfg or not cfg.rules then
return
end
for _, r in ipairs(cfg.rules) do
if r.find and r.find ~= "" then
table.insert(rules, {
on = r.on or "both",
url = r.url,
target = r.target or "body",
find = r.find,
replace = r.replace or "",
})
end
end
end
local function should_apply(rule, req, context)
if rule.on ~= "both" and rule.on ~= context then
return false
end
if rule.url and not req.url:match(rule.url) then
return false
end
return true
end
local function apply(rule, req, res, context)
local target = rule.target
if target == "body" then
local obj = (context == "response") and res or req
local body = obj:get_body()
local new_body = body:gsub(rule.find, rule.replace)
if new_body ~= body then
obj:set_body(new_body)
end
elseif target == "path" then
local new_path = req.path:gsub(rule.find, rule.replace)
if new_path ~= req.path then
req:set_path(new_path)
end
elseif target == "url" then
local new_url = req.url:gsub(rule.find, rule.replace)
if new_url ~= req.url then
req:set_url(new_url)
end
elseif target:sub(1, 7) == "header:" then
local name = target:sub(8)
local obj = (context == "response") and res or req
local val = obj.headers[name] or ""
local new_val = val:gsub(rule.find, rule.replace)
if new_val ~= val then
obj:set_header(name, new_val)
end
end
end
function on_request(req)
for _, rule in ipairs(rules) do
if should_apply(rule, req, "request") then
apply(rule, req, nil, "request")
end
end
end
function on_response(req, res)
for _, rule in ipairs(rules) do
if should_apply(rule, req, "response") then
apply(rule, req, res, "response")
end
end
end
+52 -35
View File
@@ -4,37 +4,34 @@ Plugin = {
Auto-forward requests and exclude them from history based on patterns. Auto-forward requests and exclude them from history based on patterns.
**Config**: **Config**:
- `pattern` - whitelist: only intercept matching requests/responses and history entries ```yaml
- `!pattern` - blacklist: skip matching requests/responses and history entries patterns:
- `r:pattern` - whitelist for requests/responses only (history unaffected) - "pattern" # whitelist: only intercept matching requests/responses and history
- `r:!pattern` - blacklist for requests/responses only - "!pattern" # blacklist: skip matching requests/responses and history
- `h:pattern` - whitelist for history entries only (requests unaffected) - "r:pattern" # whitelist for requests/responses only
- `h:!pattern` - blacklist for history entries only - "r:!pattern" # blacklist for requests/responses only
- lines starting with `#` are comments - "h:pattern" # whitelist for history only
- "h:!pattern" # blacklist for history only
```
Example (ignore static assets): Example (ignore static assets):
``` ```yaml
!%.css$ patterns:
!%.js$ - "!%.css$"
!%.png$ - "!%.js$"
- "!%.png$"
``` ```
Example (focus on mytarget.com, skip everything else): Example (focus on mytarget.com):
``` ```yaml
mytarget%.com/ patterns:
- "mytarget%.com/"
``` ```
Example (intercept mytarget.com except its static assets): Example (disable history):
``` ```yaml
mytarget%.com/ patterns:
!%.css$ - "h:^$"
!%.js$
!%.png$
```
Example (disable history: whitelist never matches any real URL):
```
h:^$
``` ```
]], ]],
priority = 100, priority = 100,
@@ -50,11 +47,15 @@ local whitelist_req = {}
local blacklist_hist = {} local blacklist_hist = {}
local whitelist_hist = {} local whitelist_hist = {}
function on_config(config_text) function on_config()
blacklist, whitelist = {}, {} blacklist, whitelist = {}, {}
blacklist_req, whitelist_req = {}, {} blacklist_req, whitelist_req = {}, {}
blacklist_hist, whitelist_hist = {}, {} blacklist_hist, whitelist_hist = {}, {}
for line in config_text:gmatch("[^\n]+") do local cfg = get_config()
if not cfg or not cfg.patterns then
return
end
for _, line in ipairs(cfg.patterns) do
local trimmed = line:match("^%s*(.-)%s*$") local trimmed = line:match("^%s*(.-)%s*$")
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
local scope = trimmed:match("^([rh]):") local scope = trimmed:match("^([rh]):")
@@ -74,17 +75,27 @@ end
local function check_skip(url, bl_extra, wl_extra) local function check_skip(url, bl_extra, wl_extra)
for _, p in ipairs(blacklist) do for _, p in ipairs(blacklist) do
if url:match(p) then return true end if url:match(p) then
return true
end
end end
for _, p in ipairs(bl_extra) do for _, p in ipairs(bl_extra) do
if url:match(p) then return true end if url:match(p) then
return true
end
end end
local wl = {} local wl = {}
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end for _, p in ipairs(whitelist) do
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end wl[#wl + 1] = p
end
for _, p in ipairs(wl_extra) do
wl[#wl + 1] = p
end
if #wl > 0 then if #wl > 0 then
for _, p in ipairs(wl) do for _, p in ipairs(wl) do
if url:match(p) then return false end if url:match(p) then
return false
end
end end
return true return true
end end
@@ -92,13 +103,19 @@ local function check_skip(url, bl_extra, wl_extra)
end end
function on_request(req) function on_request(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end if check_skip(req.url, blacklist_req, whitelist_req) then
return "forward"
end
end end
function on_response(req) function on_response(req)
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end if check_skip(req.url, blacklist_req, whitelist_req) then
return "forward"
end
end end
function on_history_entry(entry) function on_history_entry(entry)
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then
return "skip"
end
end end
+76 -28
View File
@@ -21,40 +21,61 @@ local CONTENT_TYPES = {
-- Key name alternation (case-insensitive via grep -i) -- Key name alternation (case-insensitive via grep -i)
-- Suffixes are required (no bare generic keyword alone). -- Suffixes are required (no bare generic keyword alone).
local KEYS = { local KEYS = {
"access(_key|_token)", "accessid_secret", "account(_key|_sid)", "access(_key|_token)",
"admin_pass(word)?", "admin_user", "accessid_secret",
"account(_key|_sid)",
"admin_pass(word)?",
"admin_user",
"(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))", "(algolia|aws|gcp|azure|heroku|firebase|github|gitlab|slack|datadog|stripe|twilio|vercel|supabase|sendgrid|cloudinary|cloudflare|bitbucket|npm|netlify|auth0|okta|sentry)(_?(api|secret|access)(_?(key|token|id|sid|secret))?|_?(key|token|id|sid|secret))",
"ansible_vault_password", "aos_key", "ansible_vault_password",
"aos_key",
"api(_key|_secret|_token)", "api(_key|_secret|_token)",
"app_(id|key|secret)", "application(_key|_id|_secret)", "app_(id|key|secret)",
"auth(_token|_secret|orization)", "authkey", "authsecret", "application(_key|_id|_secret)",
"auth(_token|_secret|orization)",
"authkey",
"authsecret",
"bearer_?token", "bearer_?token",
"bucket(_password|_key)", "bucket(_password|_key)",
"cert_?pass(word)?", "certificate_password", "cert_?pass(word)?",
"certificate_password",
"client(_id|_secret)", "client(_id|_secret)",
"codecov_token", "consumer_(key|secret)", "codecov_token",
"connection_?string", "credentials?", "crypt(_key|_secret)", "consumer_(key|secret)",
"connection_?string",
"credentials?",
"crypt(_key|_secret)",
"db_(password|passwd|user(name)?)", "db_(password|passwd|user(name)?)",
"deploy(_key|_password|_token)", "deploy(_key|_password|_token)",
"docker_?pass(word)?", "dockerhub_?password", "docker_?pass(word)?",
"dockerhub_?password",
"encryption_(key|password)", "encryption_(key|password)",
"jwt_secret", "json_web_token", "jwt_secret",
"keycloak_secret", "kubernetes_token", "json_web_token",
"ldap_(password|bindpw)", "login(_password|_token)", "keycloak_secret",
"mail_?password", "mail_smtp_pass", "kubernetes_token",
"mysql_password", "mongo_password", "ldap_(password|bindpw)",
"netlify_token", "npm(_token|_auth_token)", "login(_password|_token)",
"mail_?password",
"mail_smtp_pass",
"mysql_password",
"mongo_password",
"netlify_token",
"npm(_token|_auth_token)",
"oauth(_token|_secret)", "oauth(_token|_secret)",
"openai_(api_key|secret)", "openai_(api_key|secret)",
"pass(word)?", "passwd", "pass(word)?",
"passwd",
"private(_key|_token)", "private(_key|_token)",
"rds_password", "rds_password",
"s3(_key|_secret|_access_key_id)", "s3(_key|_secret|_access_key_id)",
"secret(_key|_token|_id)", "security_token", "secret(_key|_token|_id)",
"security_token",
"sendgrid_api_key", "sendgrid_api_key",
"ses_(smtp|access|secret)", "ses_(smtp|access|secret)",
"service(_account|_key|_token)", "service(_account|_key|_token)",
"smtp_pass(word)?", "smtp_secret", "smtp_pass(word)?",
"smtp_secret",
"sonar_token", "sonar_token",
"ssh(_key|_private_key|_rsa)", "ssh(_key|_private_key|_rsa)",
"supabase(_anon|_service)?_key", "supabase(_anon|_service)?_key",
@@ -79,10 +100,14 @@ local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'" local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
local function is_relevant(ct) local function is_relevant(ct)
if not ct or ct == "" then return false end if not ct or ct == "" then
return false
end
ct = ct:lower() ct = ct:lower()
for _, t in ipairs(CONTENT_TYPES) do for _, t in ipairs(CONTENT_TYPES) do
if ct:find(t, 1, true) then return true end if ct:find(t, 1, true) then
return true
end
end end
return false return false
end end
@@ -94,17 +119,23 @@ local function build_context(lines, linenum)
local before, after = {}, {} local before, after = {}, {}
for i = lo, linenum - 1 do for i = lo, linenum - 1 do
local l = lines[i] or "" local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end if #l > 120 then
l = l:sub(1, 120) .. "..."
end
table.insert(before, l) table.insert(before, l)
end end
for i = linenum + 1, hi do for i = linenum + 1, hi do
local l = lines[i] or "" local l = lines[i] or ""
if #l > 120 then l = l:sub(1, 120) .. "..." end if #l > 120 then
l = l:sub(1, 120) .. "..."
end
table.insert(after, l) table.insert(after, l)
end end
local matched_line = lines[linenum] or "" local matched_line = lines[linenum] or ""
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end if #matched_line > 200 then
matched_line = matched_line:sub(1, 200) .. "..."
end
local parts = {} local parts = {}
if #before > 0 then if #before > 0 then
@@ -118,15 +149,21 @@ local function build_context(lines, linenum)
end end
local function scan(label, ct, body, host, path) local function scan(label, ct, body, host, path)
if not is_relevant(ct) then return end if not is_relevant(ct) then
if not body or body == "" then return end return
end
if not body or body == "" then
return
end
local out, err = shell_pipe(GREP_CMD, body) local out, err = shell_pipe(GREP_CMD, body)
if err and err ~= "" then if err and err ~= "" then
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err) log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
return return
end end
if not out or out == "" then return end if not out or out == "" then
return
end
local lines = {} local lines = {}
for line in (body .. "\n"):gmatch("([^\n]*)\n") do for line in (body .. "\n"):gmatch("([^\n]*)\n") do
@@ -140,11 +177,22 @@ local function scan(label, ct, body, host, path)
matched = matched:match("^%s*(.-)%s*$") matched = matched:match("^%s*(.-)%s*$")
if matched ~= "" then if matched ~= "" then
local display = matched local display = matched
if #display > 200 then display = display:sub(1, 200) .. "..." end if #display > 200 then
display = display:sub(1, 200) .. "..."
end
local ctx = build_context(lines, linenum) local ctx = build_context(lines, linenum)
create_finding({ create_finding({
title = "Potential secret in " .. label .. " (" .. host .. ")", title = "Potential secret in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx, description = "**Host:** `"
.. host
.. "`\n"
.. "\n**Path:** `"
.. path
.. "`\n"
.. "\n**Match:** `"
.. display
.. "`\n\n"
.. ctx,
key = host .. "|" .. path .. "|" .. label .. "|" .. matched, key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
severity = "high", severity = "high",
}) })
+18 -12
View File
@@ -4,9 +4,6 @@ Plugin = {
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog). Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
Requires `trufflehog` v3+ to be installed and available in PATH. Requires `trufflehog` v3+ to be installed and available in PATH.
Each finding is stored on the **Findings** page with the matched detector output.
Findings are deduplicated per host+path+body content so repeated requests do not create duplicates.
]], ]],
on_start = { sync = false }, on_start = { sync = false },
on_request = { sync = false }, on_request = { sync = false },
@@ -15,9 +12,7 @@ Findings are deduplicated per host+path+body content so repeated requests do not
} }
function on_start() function on_start()
local handle = io.popen("command -v trufflehog 2>/dev/null") local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
local result = handle and handle:read("*a") or ""
if handle then handle:close() end
if not result or result:match("^%s*$") then if not result or result:match("^%s*$") then
log("trufflehog is not installed or not in PATH") log("trufflehog is not installed or not in PATH")
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error") notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
@@ -26,28 +21,39 @@ function on_start()
end end
local function scan(label, content, host, path) local function scan(label, content, host, path)
if not content or content == "" then return end if not content or content == "" then
local out, err = shell_pipe("f=$(mktemp) && cat > \"$f\" && trufflehog filesystem --no-color \"$f\"; rc=$?; rm -f \"$f\"; exit $rc", content) return
end
local out, err = shell_pipe(
'f=$(mktemp) && cat > "$f" && trufflehog filesystem --no-color "$f"; rc=$?; rm -f "$f"; exit $rc',
content
)
if err and err ~= "" then if err and err ~= "" then
log("trufflehog error on " .. label .. ": " .. err) log("trufflehog error on " .. label .. ": " .. err)
return return
end end
if not out or out == "" then return end if not out or out == "" then
return
end
local blocks = {} local blocks = {}
local current = nil local current = nil
for line in out:gmatch("[^\n]+") do for line in out:gmatch("[^\n]+") do
if line:match("^Found ") then if line:match("^Found ") then
if current then table.insert(blocks, current) end if current then
table.insert(blocks, current)
end
current = line current = line
elseif current then elseif current then
current = current .. "\n" .. line current = current .. "\n" .. line
end end
end end
if current then table.insert(blocks, current) end if current then
table.insert(blocks, current)
end
for _, block in ipairs(blocks) do for _, block in ipairs(blocks) do
create_finding({ create_finding({
title = "Secret detected in " .. label .. " (" .. host .. ")", title = "Secret detected in " .. label .. " (" .. host .. ")",
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```", description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
key = host .. "|" .. path .. "|" .. label .. "|" .. block, key = host .. "|" .. path .. "|" .. label .. "|" .. block,
severity = "high", severity = "high",
}) })
+3
View File
@@ -8,6 +8,9 @@ import (
"path/filepath" "path/filepath"
) )
//go:embed docs
var DocsFS embed.FS
//go:embed plugins/*.lua //go:embed plugins/*.lua
var PluginsFS embed.FS var PluginsFS embed.FS