mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-07-06 20:42:33 +02:00
Compare commits
32 Commits
v0.0.5
...
6128dcc15e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6128dcc15e | |||
| 41d5366952 | |||
| 8683d17242 | |||
| 2e61ffa125 | |||
| 9ac6040629 | |||
| c7523e69e7 | |||
| aa458c142f | |||
| 0b395e018a | |||
| 5841ed0a95 | |||
| 9cabe81771 | |||
| 021090f52c | |||
| 0b9e1a1cf0 | |||
| f60cdf2033 | |||
| 6af1388652 | |||
| a708830309 | |||
| 44b3c67a37 | |||
| 6e673f5c11 | |||
| 2c63cdbeff | |||
| 0e982c6ade | |||
| 04ba32cbd5 | |||
| f7e9da94cc | |||
| 9253d85c81 | |||
| 1bb547870e | |||
| 4251e4fb2a | |||
| b547a79d6e | |||
| fe58468abf | |||
| 3542098905 | |||
| f78b3f7174 | |||
| 722021ba02 | |||
| e18f660e83 | |||
| 67fe8eb911 | |||
| af872afbe8 |
@@ -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"
|
||||
@@ -23,6 +23,6 @@ jobs:
|
||||
- uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
args: release --clean --config .github/.goreleaser.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
result/
|
||||
.pre-commit-config.yaml
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
> A minimal, terminal-based HTTP(S) proxy for pentesters and CTF players.
|
||||
> Think Burp Suite or Caido, but entirely in your terminal.
|
||||
|
||||
[](go.mod)
|
||||
[](https://github.com/anotherhadi/spilltea/releases)
|
||||
[](LICENSE)
|
||||
[](https://goreportcard.com/report/github.com/anotherhadi/spilltea)
|
||||

|
||||

|
||||

|
||||
[](https://ko-fi.com/anotherhadi)
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
@@ -25,6 +25,7 @@
|
||||
- [Configuration](#configuration)
|
||||
- [CLI Flags](#cli-flags)
|
||||
- [Plugin System](#plugin-system)
|
||||
- [Vim / Neovim Integration](#vim--neovim-integration)
|
||||
- [Deployment](#deployment)
|
||||
- [Tech Stack](#tech-stack)
|
||||
|
||||
@@ -39,6 +40,7 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
|
||||
<img alt="demo" src="./.github/assets/demo.gif" width="700" />
|
||||
|
||||
<!-- exec: cat ./docs/legal-disclaimer.md -->
|
||||
|
||||
## Legal Disclaimer
|
||||
|
||||
**This tool is provided for educational purposes and authorized security testing only.**
|
||||
@@ -46,6 +48,7 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
|
||||
Use Spilltea only on systems and networks you own or have explicit written permission to test. Intercepting network traffic without authorization may violate local laws (such as the Computer Fraud and Abuse Act, GDPR, or equivalent legislation in your jurisdiction).
|
||||
|
||||
The author(s) and contributors are not responsible for any misuse, damage, or legal consequences resulting from the use of this software. By using Spilltea, you agree that you are solely responsible for ensuring your usage is lawful and authorized.
|
||||
|
||||
<!-- endexec -->
|
||||
|
||||
## Features
|
||||
@@ -100,6 +103,7 @@ environment.systemPackages = [ inputs.spilltea.packages.${pkgs.system}.default ]
|
||||
</details>
|
||||
|
||||
<!-- exec: cat ./docs/basics.md -->
|
||||
|
||||
## Project Management
|
||||
|
||||
Spilltea organizes work into **projects**. Each project maps to a SQLite database file that stores all intercepted traffic for that session & a log files.
|
||||
@@ -115,6 +119,8 @@ On startup, you choose:
|
||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
||||
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||
|
||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||
|
||||
## CLI Flags
|
||||
|
||||
```
|
||||
@@ -130,6 +136,7 @@ Usage: spilltea [flags]
|
||||
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
|
||||
-v, --version print version
|
||||
```
|
||||
|
||||
<!-- endexec -->
|
||||
|
||||
## Plugin System
|
||||
@@ -137,6 +144,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.
|
||||
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
|
||||
|
||||
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
|
||||
|
||||
+19
-4
@@ -44,7 +44,8 @@ func main() {
|
||||
)
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = func() {
|
||||
fmt.Println("Usage: spilltea [flags]\n")
|
||||
fmt.Println("Usage: spilltea [flags]")
|
||||
fmt.Println("")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
@@ -121,7 +122,7 @@ func main() {
|
||||
config.Global.App.UpstreamProxy = *flagUpstreamProxy
|
||||
}
|
||||
|
||||
style.Init(config.Global)
|
||||
style.Init()
|
||||
icons.Init(config.Global)
|
||||
keys.Init(config.Global)
|
||||
|
||||
@@ -136,17 +137,31 @@ func main() {
|
||||
}
|
||||
broker := intercept.NewBroker()
|
||||
m := appUI.New(broker, project.Name, project.Path)
|
||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
||||
final, err := tea.NewProgram(m).Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if app, ok := final.(appUI.Model); ok {
|
||||
if ferr := app.FatalErr(); ferr != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", ferr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Run home + app in a single program to avoid a blank flash on transition.
|
||||
root := rootModel{home: homeUI.New(projectDir)}
|
||||
if _, err := tea.NewProgram(root).Run(); err != nil {
|
||||
final, err := tea.NewProgram(root).Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if r, ok := final.(rootModel); ok {
|
||||
if ferr := r.FatalErr(); ferr != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", ferr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,3 +58,12 @@ func (m rootModel) View() tea.View {
|
||||
}
|
||||
return m.home.View()
|
||||
}
|
||||
|
||||
func (m rootModel) FatalErr() error {
|
||||
if m.state == rootStateApp {
|
||||
if app, ok := m.app.(appUI.Model); ok {
|
||||
return app.FatalErr()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package spilltea
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed docs
|
||||
var DocsFS embed.FS
|
||||
@@ -13,6 +13,8 @@ On startup, you choose:
|
||||
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
|
||||
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
|
||||
|
||||
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
|
||||
|
||||
## CLI Flags
|
||||
|
||||
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## How do I convert a temporary session into a regular project?
|
||||
|
||||
Temporary sessions are stored under `/tmp/spilltea/<random-id>/` and will be lost on reboot. To keep the data, move the directory into the Spilltea projects folder and give it a name.
|
||||
|
||||
**1. Find the temporary session directory**
|
||||
|
||||
```bash
|
||||
ls /tmp/spilltea/
|
||||
```
|
||||
|
||||
You will see one or more directories with a random hex name (e.g. `a1b2c3d4`).
|
||||
|
||||
**2. Move it to the projects folder**
|
||||
|
||||
```bash
|
||||
mv /tmp/spilltea/<random-id> ~/.local/share/spilltea/<project-name>
|
||||
```
|
||||
|
||||
The project name must only contain lowercase letters, digits, `-`, and `_`.
|
||||
|
||||
**3. Open the project**
|
||||
|
||||
```bash
|
||||
spilltea -P my-project
|
||||
```
|
||||
|
||||
Or simply launch Spilltea and pick `my-project` from the project list.
|
||||
+77
-51
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
You can found some pre-built plugins [here](../../plugins/).
|
||||
|
||||
@@ -31,14 +33,14 @@ Plugin = {
|
||||
|
||||
### Hook reference
|
||||
|
||||
| Hook | When called | Sync/async | Return value |
|
||||
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------- |
|
||||
| `on_config(config_text)` | 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_quit()` | When the app exits | always sync | ignored |
|
||||
| `on_request(req)` | Every request, before auto-forward | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||
| `on_response(req, res)` | Every response | configurable | `"drop"`, `"forward"`, or `nil` (sync only) |
|
||||
| `on_history_entry(entry)` | Sync: before DB insert / Async: after | configurable | `"skip"` (don't save), `"keep"` or `nil` (save) -- sync only |
|
||||
| Hook | When called | Sync/async | Return value |
|
||||
| ------------------------- | ------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `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_quit()` | When the app exits | always sync | ignored |
|
||||
| `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` (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) |
|
||||
|
||||
## Request and response objects
|
||||
|
||||
@@ -120,17 +122,77 @@ if err then
|
||||
else
|
||||
log("output: " .. out)
|
||||
end
|
||||
|
||||
-- Send an HTTP request directly (bypasses the proxy pipeline and other plugins).
|
||||
-- method: HTTP method ("GET", "POST", etc.)
|
||||
-- url: full URL to request
|
||||
-- headers: table of request headers (optional, pass {} if unused)
|
||||
-- body: request body string (optional, pass "" if unused)
|
||||
-- options: optional table of options:
|
||||
-- insecure = true skip TLS certificate verification
|
||||
-- Returns: response table, error string (nil on success).
|
||||
local res, err = send_request("POST", "https://example.com/api", {
|
||||
["Authorization"] = "Bearer " .. token,
|
||||
["Content-Type"] = "application/json",
|
||||
}, '{"key":"value"}', { insecure = true })
|
||||
if err then
|
||||
log("request failed: " .. err)
|
||||
else
|
||||
log(tostring(res.status_code))
|
||||
log(res.body)
|
||||
log(res.headers["Content-Type"] or "")
|
||||
end
|
||||
|
||||
-- Return the plugin's config section as a Lua table (parsed from YAML).
|
||||
-- Returns an empty table if no config is set.
|
||||
local cfg = get_config()
|
||||
```
|
||||
|
||||
### Finding deduplication
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### Return values for sync hooks
|
||||
|
||||
**`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.
|
||||
Sync `on_history_entry` runs **before** the DB insert, so it can prevent an entry from ever appearing in history.
|
||||
Async `on_history_entry` runs **after** the insert and cannot affect it.
|
||||
|
||||
## Priority
|
||||
|
||||
Plugins with a higher `priority` value run before plugins with a lower value (default `0`). This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
|
||||
Plugins with a higher `priority` value run before plugins with a lower value (default `0`).
|
||||
This matters for sync hooks that return a decision: the first plugin to return a non-nil value short-circuits the remaining plugins.
|
||||
|
||||
```lua
|
||||
Plugin = {
|
||||
|
||||
Generated
+115
@@ -1,5 +1,103 @@
|
||||
{
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1777954456,
|
||||
@@ -18,8 +116,25 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"git-hooks": "git-hooks",
|
||||
"gomod2nix": "gomod2nix",
|
||||
"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",
|
||||
|
||||
@@ -1,55 +1,41 @@
|
||||
{
|
||||
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 = {
|
||||
self,
|
||||
nixpkgs,
|
||||
gomod2nix,
|
||||
git-hooks,
|
||||
}: let
|
||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||
|
||||
forAllSystems = f:
|
||||
nixpkgs.lib.genAttrs supportedSystems
|
||||
(system: f system (import nixpkgs {inherit system;}));
|
||||
|
||||
pname = "spilltea";
|
||||
version = "0.0.5";
|
||||
|
||||
ldflags = ["-s" "-w" "-X main.version=${version}"];
|
||||
in {
|
||||
packages = forAllSystems (system: pkgs: let
|
||||
pkg = pkgs.buildGoModule {
|
||||
inherit pname version ldflags;
|
||||
|
||||
src = ./.;
|
||||
outputs = ["out"];
|
||||
|
||||
vendorHash = "sha256-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;
|
||||
});
|
||||
packages = forAllSystems (system: pkgs:
|
||||
import ./nix/package.nix {
|
||||
inherit pkgs;
|
||||
buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system: pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
go
|
||||
python3
|
||||
lefthook
|
||||
doctoc
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
lefthook install
|
||||
'';
|
||||
default = import ./nix/shell.nix {
|
||||
inherit pkgs;
|
||||
gitHooksLib = git-hooks.lib.${system};
|
||||
gomod2nixPkgs = gomod2nix.legacyPackages.${system};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ require (
|
||||
charm.land/bubbletea/v2 v2.0.6
|
||||
charm.land/glamour/v2 v2.0.0
|
||||
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/klauspost/compress v1.17.8
|
||||
github.com/lqqyt2423/go-mitmproxy v1.8.11
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/pflag v1.0.10
|
||||
@@ -20,7 +23,6 @@ require (
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
@@ -39,7 +41,6 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/klauspost/compress v1.17.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
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/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -31,7 +31,6 @@ type Config struct {
|
||||
} `mapstructure:"app"`
|
||||
|
||||
TUI struct {
|
||||
Colors Colors `mapstructure:"colors"`
|
||||
UseNerdfontIcons bool `mapstructure:"use_nerdfont_icons"`
|
||||
DefaultSidebarState string `mapstructure:"default_sidebar_state"`
|
||||
PrettyPrintBody bool `mapstructure:"pretty_print_body"`
|
||||
@@ -41,6 +40,7 @@ type Config struct {
|
||||
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
|
||||
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
|
||||
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
|
||||
QueueSize int `mapstructure:"queue_size"`
|
||||
} `mapstructure:"intercept"`
|
||||
|
||||
Replay struct {
|
||||
@@ -53,6 +53,13 @@ type Config struct {
|
||||
} `mapstructure:"history"`
|
||||
|
||||
Keybindings Keybindings `mapstructure:"keybindings"`
|
||||
|
||||
Plugins map[string]GlobalPlugin `mapstructure:"plugins"`
|
||||
}
|
||||
|
||||
type GlobalPlugin struct {
|
||||
Enable *bool `mapstructure:"enable"`
|
||||
Config interface{} `mapstructure:"config"`
|
||||
}
|
||||
|
||||
var Global *Config
|
||||
@@ -82,7 +89,7 @@ func WriteDefaultConfig(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, defaultConfig, 0o644); err != nil {
|
||||
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,13 +5,14 @@ app:
|
||||
project_dir: ~/.local/share/spilltea
|
||||
plugins_dir: ~/.config/spilltea/plugins
|
||||
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)
|
||||
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
|
||||
|
||||
intercept:
|
||||
default_intercept_enabled: true
|
||||
default_capture_response: false
|
||||
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks
|
||||
auto_forward_regex:
|
||||
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
|
||||
|
||||
@@ -26,23 +27,6 @@ tui:
|
||||
use_nerdfont_icons: false
|
||||
default_sidebar_state: "expanded" # hidden, collapsed, expanded
|
||||
pretty_print_body: true # auto-indent JSON and HTML response bodies
|
||||
colors:
|
||||
base00: "110F12" # Default Background
|
||||
base01: "1C1920" # Lighter Background (status bars, line numbers)
|
||||
base02: "1D1A26" # Selection Background
|
||||
base03: "514D63" # Comments, Invisibles, faint text
|
||||
base04: "8E8AA0" # Dark Foreground (status bars)
|
||||
base05: "C2BED6" # Default Foreground, Caret, Delimiters
|
||||
base06: "D8D5EA" # Light Foreground (rarely used)
|
||||
base07: "EAE7F7" # Light Background (rarely used)
|
||||
base08: "E07080" # Red: errors, diff deleted
|
||||
base09: "D49070" # Orange: integers, constants, warnings
|
||||
base0a: "C4B060" # Yellow: classes, search highlight
|
||||
base0b: "80B880" # Green: strings, diff inserted, success
|
||||
base0c: "70B8C0" # Cyan: support, regex, escape chars
|
||||
base0d: "9E97F8" # Blue/Accent: functions, headings, primary
|
||||
base0e: "C090E8" # Purple: keywords, storage
|
||||
base0f: "D080A0" # Pink: deprecated, embedded language tags
|
||||
|
||||
keybindings:
|
||||
global:
|
||||
|
||||
+14
-6
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
roConn *sql.DB
|
||||
path string
|
||||
dedupMu sync.Mutex
|
||||
}
|
||||
@@ -27,6 +28,17 @@ func Open(path string) (*DB, error) {
|
||||
conn.Close()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,12 +72,7 @@ CREATE TABLE IF NOT EXISTS replay_entries (
|
||||
status_code INTEGER NOT NULL,
|
||||
error_msg TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
name TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
config_text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS findings (
|
||||
CREATE TABLE IF NOT EXISTS findings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_name TEXT NOT NULL,
|
||||
dedup_key TEXT NOT NULL,
|
||||
@@ -94,6 +101,7 @@ func (d *DB) Close() error {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
_ = d.roConn.Close()
|
||||
return d.conn.Close()
|
||||
}
|
||||
|
||||
|
||||
+24
-12
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -63,7 +64,11 @@ func (d *DB) InsertEntry(e Entry, body string) (Entry, error) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -113,19 +118,11 @@ func (d *DB) SearchEntries(term string) ([]Entry, error) {
|
||||
|
||||
// QueryEntries runs a WHERE expression supplied by the user against the entries
|
||||
// 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
|
||||
// user-supplied expression is rejected by SQLite before it can execute.
|
||||
// Uses the persistent read-only connection (PRAGMA query_only=ON) so that any
|
||||
// DML or DDL in the user-supplied expression is rejected by SQLite before it executes.
|
||||
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)
|
||||
rows, err := roConn.Query(q)
|
||||
rows, err := d.roConn.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,3 +144,18 @@ func (d *DB) DeleteAllEntries() error {
|
||||
_, err := d.conn.Exec(`DELETE FROM entries`)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
|
||||
func (d *DB) LoadFindings() ([]Finding, error) {
|
||||
rows, err := d.conn.Query(
|
||||
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at
|
||||
FROM findings WHERE dismissed = 0 ORDER BY id DESC`,
|
||||
FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package db
|
||||
|
||||
type PluginState struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
ConfigText string
|
||||
}
|
||||
|
||||
func (d *DB) SavePluginState(name string, enabled bool, configText string) error {
|
||||
_, err := d.conn.Exec(
|
||||
`INSERT INTO plugins (name, enabled, config_text) VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET enabled = excluded.enabled, config_text = excluded.config_text`,
|
||||
name, enabled, configText,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) LoadPluginStates() ([]PluginState, error) {
|
||||
rows, err := d.conn.Query(`SELECT name, enabled, config_text FROM plugins`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PluginState
|
||||
for rows.Next() {
|
||||
var s PluginState
|
||||
var enabled int
|
||||
if err := rows.Scan(&s.Name, &enabled, &s.ConfigText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Enabled = enabled != 0
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -58,9 +58,13 @@ func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
|
||||
}
|
||||
|
||||
func NewBroker() *Broker {
|
||||
size := config.Global.Intercept.QueueSize
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
}
|
||||
b := &Broker{
|
||||
Incoming: make(chan *PendingRequest, 64),
|
||||
IncomingResponse: make(chan *PendingResponse, 64),
|
||||
Incoming: make(chan *PendingRequest, size),
|
||||
IncomingResponse: make(chan *PendingResponse, size),
|
||||
}
|
||||
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
|
||||
return b
|
||||
|
||||
@@ -3,14 +3,20 @@ package plugins
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
|
||||
@@ -175,6 +181,99 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
|
||||
return 0
|
||||
}))
|
||||
|
||||
L.SetGlobal("get_config", L.NewFunction(func(L *lua.LState) int {
|
||||
// p.mu is already held by the hook caller - do not lock again.
|
||||
configText := p.ConfigText
|
||||
if configText == "" {
|
||||
L.Push(L.NewTable())
|
||||
return 1
|
||||
}
|
||||
var data interface{}
|
||||
if err := yaml.Unmarshal([]byte(configText), &data); err != nil || data == nil {
|
||||
L.Push(L.NewTable())
|
||||
return 1
|
||||
}
|
||||
lv := goToLuaValue(L, data)
|
||||
if _, ok := lv.(*lua.LTable); !ok {
|
||||
L.Push(L.NewTable())
|
||||
return 1
|
||||
}
|
||||
L.Push(lv)
|
||||
return 1
|
||||
}))
|
||||
|
||||
L.SetGlobal("send_request", L.NewFunction(func(L *lua.LState) int {
|
||||
method := L.CheckString(1)
|
||||
rawURL := L.CheckString(2)
|
||||
hdrs := L.OptTable(3, L.NewTable())
|
||||
body := L.OptString(4, "")
|
||||
opts := L.OptTable(5, L.NewTable())
|
||||
|
||||
insecure := false
|
||||
if v, ok := L.GetField(opts, "insecure").(lua.LBool); ok {
|
||||
insecure = bool(v)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||
}
|
||||
if up := config.Global.App.UpstreamProxy; up != "" {
|
||||
if proxyURL, err := url.Parse(up); err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, rawURL, bodyReader)
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
hdrs.ForEach(func(k, v lua.LValue) {
|
||||
ks, kok := k.(lua.LString)
|
||||
vs, vok := v.(lua.LString)
|
||||
if kok && vok {
|
||||
req.Header.Set(string(ks), string(vs))
|
||||
}
|
||||
})
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
|
||||
result := L.NewTable()
|
||||
L.SetField(result, "status_code", lua.LNumber(resp.StatusCode))
|
||||
L.SetField(result, "body", lua.LString(string(respBody)))
|
||||
respHeaders := L.NewTable()
|
||||
for k, vals := range resp.Header {
|
||||
L.SetField(respHeaders, k, lua.LString(strings.Join(vals, ", ")))
|
||||
}
|
||||
L.SetField(result, "headers", respHeaders)
|
||||
|
||||
L.Push(result)
|
||||
L.Push(lua.LNil)
|
||||
return 2
|
||||
}))
|
||||
|
||||
L.SetGlobal("shell_pipe", L.NewFunction(func(L *lua.LState) int {
|
||||
cmd := L.CheckString(1)
|
||||
input := L.OptString(2, "")
|
||||
@@ -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 {
|
||||
v := t.RawGetString(key)
|
||||
if s, ok := v.(lua.LString); ok {
|
||||
@@ -241,6 +369,21 @@ func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+70
-54
@@ -13,14 +13,23 @@ import (
|
||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
plugins []*Plugin
|
||||
|
||||
db *db.DB
|
||||
broker *intercept.Broker
|
||||
db *db.DB
|
||||
pluginsFile *PluginsFile
|
||||
broker *intercept.Broker
|
||||
globalDefaults map[string]GlobalDefault
|
||||
|
||||
Notifs chan PluginNotifMsg
|
||||
Quit chan string
|
||||
@@ -43,6 +52,14 @@ func (m *Manager) SetDB(d *db.DB) {
|
||||
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 {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -52,17 +69,6 @@ func (m *Manager) LoadFromDir(dir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var states map[string]db.PluginState
|
||||
if m.db != nil {
|
||||
list, err := m.db.LoadPluginStates()
|
||||
if err == nil {
|
||||
states = make(map[string]db.PluginState, len(list))
|
||||
for _, s := range list {
|
||||
states[s.Name] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
|
||||
continue
|
||||
@@ -73,9 +79,23 @@ func (m *Manager) LoadFromDir(dir string) error {
|
||||
log.Printf("plugin load error %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if s, ok := states[p.Name]; ok {
|
||||
p.Enabled = s.Enabled
|
||||
p.ConfigText = s.ConfigText
|
||||
if m.pluginsFile != nil {
|
||||
if enabled, configText, found := m.pluginsFile.get(p.ID); found {
|
||||
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.plugins = append(m.plugins, p)
|
||||
@@ -89,6 +109,7 @@ func (m *Manager) LoadFromDir(dir string) error {
|
||||
|
||||
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||
p := &Plugin{
|
||||
ID: strings.TrimSuffix(filepath.Base(path), ".lua"),
|
||||
FilePath: path,
|
||||
Enabled: true,
|
||||
hooks: make(map[string]HookConfig),
|
||||
@@ -109,7 +130,7 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
|
||||
p.Name = string(s)
|
||||
}
|
||||
if p.Name == "" {
|
||||
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
|
||||
p.Name = p.ID
|
||||
}
|
||||
|
||||
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_history_entry": false,
|
||||
}
|
||||
// Fixed-sync hooks: always sync, not configurable.
|
||||
fixedSyncHooks := map[string]struct{}{
|
||||
"on_config": {},
|
||||
"on_quit": {},
|
||||
}
|
||||
|
||||
for hookName, defaultSync := range configurableHooks {
|
||||
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
|
||||
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}
|
||||
}
|
||||
}
|
||||
for hookName := range fixedSyncHooks {
|
||||
if p.L.GetGlobal(hookName) != lua.LNil {
|
||||
p.hooks[hookName] = HookConfig{Sync: true}
|
||||
for _, fixedSync := range []string{"on_config", "on_quit"} {
|
||||
if p.L.GetGlobal(fixedSync) != lua.LNil {
|
||||
p.hooks[fixedSync] = HookConfig{Sync: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +178,11 @@ func (m *Manager) GetPlugins() []*Plugin {
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) TogglePlugin(name string) {
|
||||
func (m *Manager) TogglePlugin(id string) {
|
||||
m.mu.RLock()
|
||||
var found *Plugin
|
||||
for _, p := range m.plugins {
|
||||
if p.Name == name {
|
||||
if p.ID == id {
|
||||
found = p
|
||||
break
|
||||
}
|
||||
@@ -179,10 +194,11 @@ func (m *Manager) TogglePlugin(name string) {
|
||||
found.mu.Lock()
|
||||
found.Enabled = !found.Enabled
|
||||
enabled := found.Enabled
|
||||
configText := found.ConfigText
|
||||
found.mu.Unlock()
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(name, enabled, configText)
|
||||
if m.pluginsFile != nil {
|
||||
if err := m.pluginsFile.setEnabled(id, enabled); err != nil {
|
||||
log.Printf("plugin %s: save state: %v", id, err)
|
||||
}
|
||||
}
|
||||
if !enabled {
|
||||
return
|
||||
@@ -194,8 +210,10 @@ func (m *Manager) TogglePlugin(name string) {
|
||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||
if ret == lua.LFalse {
|
||||
p.Enabled = false
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||
if m.pluginsFile != nil {
|
||||
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()
|
||||
var found *Plugin
|
||||
for _, p := range m.plugins {
|
||||
if p.Name == name {
|
||||
if p.ID == id {
|
||||
found = p
|
||||
break
|
||||
}
|
||||
@@ -237,39 +255,35 @@ func (m *Manager) SaveConfig(name, configText string) {
|
||||
}
|
||||
found.mu.Lock()
|
||||
found.ConfigText = configText
|
||||
enabled := found.Enabled
|
||||
_, hasOnConfig := found.hooks["on_config"]
|
||||
found.mu.Unlock()
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(name, enabled, configText)
|
||||
if m.pluginsFile != nil {
|
||||
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
|
||||
}
|
||||
// on_config is always sync.
|
||||
found.mu.Lock()
|
||||
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
|
||||
log.Printf("plugin %s on_config (config reload): %v", name, err)
|
||||
if _, err := callHook(found, "on_config"); err != nil {
|
||||
log.Printf("plugin %s on_config: %v", id, err)
|
||||
}
|
||||
found.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) RunOnStart() {
|
||||
// on_config runs first, always sync, for every enabled plugin that has it.
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if _, ok := p.hooks["on_config"]; !ok {
|
||||
continue
|
||||
if _, ok := p.hooks["on_config"]; ok {
|
||||
p.mu.Lock()
|
||||
if _, err := callHook(p, "on_config"); err != nil {
|
||||
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
p.mu.Lock()
|
||||
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
|
||||
log.Printf("plugin %s on_config: %v", p.Name, err)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
// on_start runs after, sync or async depending on plugin config.
|
||||
for _, p := range m.GetPlugins() {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
@@ -281,8 +295,10 @@ func (m *Manager) RunOnStart() {
|
||||
disableIfFalse := func(p *Plugin, ret lua.LValue) {
|
||||
if ret == lua.LFalse {
|
||||
p.Enabled = false
|
||||
if m.db != nil {
|
||||
_ = m.db.SavePluginState(p.Name, false, p.ConfigText)
|
||||
if m.pluginsFile != nil {
|
||||
if err := m.pluginsFile.setEnabled(p.ID, false); err != nil {
|
||||
log.Printf("plugin %s: save state: %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type HookConfig struct {
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
FilePath string
|
||||
@@ -37,6 +38,7 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
FilePath string
|
||||
@@ -57,6 +59,7 @@ func (p *Plugin) Info() Info {
|
||||
hooks[k] = v
|
||||
}
|
||||
return Info{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
FilePath: p.FilePath,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -46,7 +47,6 @@ func (a *interceptAddon) Request(f *goproxy.Flow) {
|
||||
switch a.plugins.RunSyncOnRequest(f) {
|
||||
case intercept.Drop:
|
||||
f.Response = dropResponse()
|
||||
go a.plugins.RunAsyncOnRequest(f)
|
||||
return
|
||||
case intercept.Forward:
|
||||
go a.plugins.RunAsyncOnRequest(f)
|
||||
@@ -133,7 +133,9 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
|
||||
wantUser, wantPass := parts[0], parts[1]
|
||||
p.SetAuthProxy(func(res http.ResponseWriter, req *http.Request) (bool, error) {
|
||||
user, pass, ok := parseBasicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||
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"`)
|
||||
return false, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// PanelContentH returns the usable inner content height for a panel rendered by
|
||||
// RenderWithTitle. It subtracts the two border lines (top + bottom) from the
|
||||
// total panel height.
|
||||
func PanelContentH(totalH int) int {
|
||||
h := totalH - 2
|
||||
if h < 0 {
|
||||
return 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RenderWithTitle renders a lipgloss bordered box with a title embedded in the
|
||||
// top border, matching the border's own foreground color. height is the total
|
||||
// desired output height (including both border lines).
|
||||
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
|
||||
boxH := height - 1
|
||||
if contentH := boxH - 1; contentH > 0 {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > contentH {
|
||||
content = strings.Join(lines[:contentH], "\n")
|
||||
}
|
||||
}
|
||||
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
|
||||
|
||||
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
|
||||
label := " " + title + " "
|
||||
fillW := boxWidth - lipgloss.Width(label) - 2
|
||||
if fillW < 0 {
|
||||
fillW = 0
|
||||
}
|
||||
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
|
||||
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
|
||||
}
|
||||
@@ -1,64 +1,23 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
"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 {
|
||||
base := lipgloss.NewStyle().Bold(true)
|
||||
switch sev {
|
||||
case "critical":
|
||||
return base.Foreground(S.Error)
|
||||
return base.Foreground(ilovetui.S.Error)
|
||||
case "high":
|
||||
return base.Foreground(S.Warning)
|
||||
return base.Foreground(ilovetui.S.Warning)
|
||||
case "medium":
|
||||
return base.Foreground(S.Primary)
|
||||
return base.Foreground(ilovetui.S.Primary)
|
||||
case "low":
|
||||
return base.Foreground(S.Success)
|
||||
return base.Foreground(ilovetui.S.Success)
|
||||
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)
|
||||
switch {
|
||||
case code >= 500:
|
||||
return base.Foreground(S.Error)
|
||||
return base.Foreground(ilovetui.S.Error)
|
||||
case code >= 400:
|
||||
return base.Foreground(S.Warning)
|
||||
return base.Foreground(ilovetui.S.Warning)
|
||||
case code >= 300:
|
||||
return base.Foreground(S.Primary)
|
||||
return base.Foreground(ilovetui.S.Primary)
|
||||
default:
|
||||
return base.Foreground(S.Success)
|
||||
return base.Foreground(ilovetui.S.Success)
|
||||
}
|
||||
}
|
||||
|
||||
// SplitH splits totalHeight into top and bottom sections, accounting for the
|
||||
// status bar height.
|
||||
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
|
||||
statusH := strings.Count(statusBar, "\n") + 1
|
||||
available := totalHeight - statusH
|
||||
top = int(float64(available) * ratio)
|
||||
bottom = available - top
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
|
||||
"charm.land/glamour/v2/ansi"
|
||||
)
|
||||
|
||||
func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig {
|
||||
c := cfg.TUI.Colors
|
||||
|
||||
str := func(s string) *string { return &s }
|
||||
hex := func(base string) *string { return str("#" + base) }
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
uintPtr := func(u uint) *uint { return &u }
|
||||
|
||||
return ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n",
|
||||
BlockSuffix: "\n",
|
||||
Color: hex(c.Base05),
|
||||
},
|
||||
Margin: uintPtr(2),
|
||||
},
|
||||
BlockQuote: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: hex(c.Base03),
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
Indent: uintPtr(1),
|
||||
IndentToken: str("│ "),
|
||||
},
|
||||
List: ansi.StyleList{
|
||||
LevelIndent: 2,
|
||||
},
|
||||
Heading: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
BlockSuffix: "\n",
|
||||
Color: hex(c.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H1: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: " ",
|
||||
Suffix: " ",
|
||||
Color: hex(c.Base07),
|
||||
BackgroundColor: hex(c.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H2: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "## ",
|
||||
Color: hex(c.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
},
|
||||
H3: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "### ",
|
||||
Color: hex(c.Base0C),
|
||||
},
|
||||
},
|
||||
H4: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "#### ",
|
||||
Color: hex(c.Base0B),
|
||||
},
|
||||
},
|
||||
H5: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "##### ",
|
||||
Color: hex(c.Base09),
|
||||
},
|
||||
},
|
||||
H6: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: "###### ",
|
||||
Color: hex(c.Base08),
|
||||
Bold: boolPtr(false),
|
||||
},
|
||||
},
|
||||
Strikethrough: ansi.StylePrimitive{
|
||||
CrossedOut: boolPtr(true),
|
||||
},
|
||||
Emph: ansi.StylePrimitive{
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
Strong: ansi.StylePrimitive{
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
HorizontalRule: ansi.StylePrimitive{
|
||||
Color: hex(c.Base03),
|
||||
Format: "\n--------\n",
|
||||
},
|
||||
Item: ansi.StylePrimitive{
|
||||
BlockPrefix: "• ",
|
||||
},
|
||||
Enumeration: ansi.StylePrimitive{
|
||||
BlockPrefix: ". ",
|
||||
},
|
||||
Task: ansi.StyleTask{
|
||||
Ticked: "[✓] ",
|
||||
Unticked: "[ ] ",
|
||||
},
|
||||
Link: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0C),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
LinkText: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0D),
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
Image: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0C),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
ImageText: ansi.StylePrimitive{
|
||||
Color: hex(c.Base04),
|
||||
Format: "Image: {{.text}} ->",
|
||||
},
|
||||
Code: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Prefix: " ",
|
||||
Suffix: " ",
|
||||
Color: hex(c.Base0B),
|
||||
BackgroundColor: hex(c.Base01),
|
||||
},
|
||||
},
|
||||
CodeBlock: ansi.StyleCodeBlock{
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{
|
||||
Color: hex(c.Base04),
|
||||
},
|
||||
Margin: uintPtr(2),
|
||||
},
|
||||
Chroma: &ansi.Chroma{
|
||||
Text: ansi.StylePrimitive{
|
||||
Color: hex(c.Base05),
|
||||
},
|
||||
Error: ansi.StylePrimitive{
|
||||
Color: hex(c.Base07),
|
||||
BackgroundColor: hex(c.Base08),
|
||||
},
|
||||
Comment: ansi.StylePrimitive{
|
||||
Color: hex(c.Base03),
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
CommentPreproc: ansi.StylePrimitive{
|
||||
Color: hex(c.Base09),
|
||||
},
|
||||
Keyword: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0E),
|
||||
},
|
||||
KeywordReserved: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0E),
|
||||
},
|
||||
KeywordNamespace: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0D),
|
||||
},
|
||||
KeywordType: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0A),
|
||||
},
|
||||
Operator: ansi.StylePrimitive{
|
||||
Color: hex(c.Base05),
|
||||
},
|
||||
Punctuation: ansi.StylePrimitive{
|
||||
Color: hex(c.Base05),
|
||||
},
|
||||
Name: ansi.StylePrimitive{
|
||||
Color: hex(c.Base05),
|
||||
},
|
||||
NameBuiltin: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0D),
|
||||
},
|
||||
NameTag: ansi.StylePrimitive{
|
||||
Color: hex(c.Base08),
|
||||
},
|
||||
NameAttribute: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0A),
|
||||
},
|
||||
NameClass: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0A),
|
||||
Bold: boolPtr(true),
|
||||
Underline: boolPtr(true),
|
||||
},
|
||||
NameConstant: ansi.StylePrimitive{
|
||||
Color: hex(c.Base09),
|
||||
},
|
||||
NameDecorator: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0C),
|
||||
},
|
||||
NameFunction: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0D),
|
||||
},
|
||||
LiteralNumber: ansi.StylePrimitive{
|
||||
Color: hex(c.Base09),
|
||||
},
|
||||
LiteralString: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0B),
|
||||
},
|
||||
LiteralStringEscape: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0C),
|
||||
},
|
||||
GenericDeleted: ansi.StylePrimitive{
|
||||
Color: hex(c.Base08),
|
||||
},
|
||||
GenericEmph: ansi.StylePrimitive{
|
||||
Italic: boolPtr(true),
|
||||
},
|
||||
GenericInserted: ansi.StylePrimitive{
|
||||
Color: hex(c.Base0B),
|
||||
},
|
||||
GenericStrong: ansi.StylePrimitive{
|
||||
Bold: boolPtr(true),
|
||||
},
|
||||
GenericSubheading: ansi.StylePrimitive{
|
||||
Color: hex(c.Base04),
|
||||
},
|
||||
Background: ansi.StylePrimitive{
|
||||
BackgroundColor: hex(c.Base01),
|
||||
},
|
||||
},
|
||||
},
|
||||
Table: ansi.StyleTable{
|
||||
StyleBlock: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{},
|
||||
},
|
||||
},
|
||||
DefinitionDescription: ansi.StylePrimitive{
|
||||
BlockPrefix: "\n> ",
|
||||
},
|
||||
}
|
||||
}
|
||||
+27
-25
@@ -3,12 +3,13 @@ package style
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"golang.org/x/net/html"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
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 {
|
||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\r", "\n")
|
||||
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||
idx := strings.Index(raw, "\n\n")
|
||||
if idx == -1 {
|
||||
return highlightHeaders(raw)
|
||||
@@ -74,8 +76,8 @@ func highlightHeaders(raw string) string {
|
||||
} else if trimmed == "" {
|
||||
out.WriteString(line)
|
||||
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
|
||||
out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
|
||||
out.WriteString(Paint(S.Text, trimmed[idx+2:]))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, trimmed[:idx+2]))
|
||||
out.WriteString(Paint(ilovetui.S.Text, trimmed[idx+2:]))
|
||||
} else {
|
||||
out.WriteString(line)
|
||||
}
|
||||
@@ -94,16 +96,16 @@ func highlightStatusLine(line string) string {
|
||||
switch parts[0] {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
|
||||
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
|
||||
result += Paint(S.Primary, parts[1])
|
||||
result += Paint(ilovetui.S.Primary, parts[1])
|
||||
if len(parts) == 3 {
|
||||
result += " " + Paint(S.Subtle, parts[2])
|
||||
result += " " + Paint(ilovetui.S.Subtle, parts[2])
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := Paint(S.Subtle, parts[0]) + " "
|
||||
result += Paint(S.Warning, parts[1])
|
||||
result := Paint(ilovetui.S.Subtle, parts[0]) + " "
|
||||
result += Paint(ilovetui.S.Warning, parts[1])
|
||||
if len(parts) == 3 {
|
||||
result += " " + Paint(S.MutedFg, parts[2])
|
||||
result += " " + Paint(ilovetui.S.Muted, parts[2])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -133,9 +135,9 @@ func highlightJSON(s string) string {
|
||||
k++
|
||||
}
|
||||
if k < n && s[k] == ':' {
|
||||
out.WriteString(Paint(S.Primary, str))
|
||||
out.WriteString(Paint(ilovetui.S.Primary, str))
|
||||
} else {
|
||||
out.WriteString(Paint(S.Success, str))
|
||||
out.WriteString(Paint(ilovetui.S.Success, str))
|
||||
}
|
||||
i = j
|
||||
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] == '-') {
|
||||
j++
|
||||
}
|
||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
||||
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||
i = j
|
||||
case i+4 <= n && s[i:i+4] == "true":
|
||||
out.WriteString(Paint(S.Error, "true"))
|
||||
out.WriteString(Paint(ilovetui.S.Error, "true"))
|
||||
i += 4
|
||||
case i+5 <= n && s[i:i+5] == "false":
|
||||
out.WriteString(Paint(S.Error, "false"))
|
||||
out.WriteString(Paint(ilovetui.S.Error, "false"))
|
||||
i += 5
|
||||
case i+4 <= n && s[i:i+4] == "null":
|
||||
out.WriteString(Paint(S.Error, "null"))
|
||||
out.WriteString(Paint(ilovetui.S.Error, "null"))
|
||||
i += 4
|
||||
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
|
||||
out.WriteString(Paint(S.Subtle, string(ch)))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, string(ch)))
|
||||
i++
|
||||
default:
|
||||
out.WriteByte(ch)
|
||||
@@ -264,11 +266,11 @@ func highlightHTML(s string) string {
|
||||
if i+4 <= n && s[i:i+4] == "<!--" {
|
||||
end := strings.Index(s[i:], "-->")
|
||||
if end == -1 {
|
||||
out.WriteString(Paint(S.Subtle, s[i:]))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, s[i:]))
|
||||
break
|
||||
}
|
||||
end = i + end + 3
|
||||
out.WriteString(Paint(S.Subtle, s[i:end]))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, s[i:end]))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
@@ -277,10 +279,10 @@ func highlightHTML(s string) string {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.WriteString(Paint(S.Subtle, "<"))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, "<"))
|
||||
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++
|
||||
}
|
||||
j := i
|
||||
@@ -288,7 +290,7 @@ func highlightHTML(s string) string {
|
||||
j++
|
||||
}
|
||||
if j > i {
|
||||
out.WriteString(Paint(S.Primary, s[i:j]))
|
||||
out.WriteString(Paint(ilovetui.S.Primary, s[i:j]))
|
||||
i = j
|
||||
}
|
||||
for i < n && s[i] != '>' {
|
||||
@@ -298,10 +300,10 @@ func highlightHTML(s string) string {
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
case ch == '/':
|
||||
out.WriteString(Paint(S.Subtle, "/"))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, "/"))
|
||||
i++
|
||||
case ch == '=':
|
||||
out.WriteString(Paint(S.Subtle, "="))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, "="))
|
||||
i++
|
||||
case ch == '"' || ch == '\'':
|
||||
q := ch
|
||||
@@ -312,19 +314,19 @@ func highlightHTML(s string) string {
|
||||
if j < n {
|
||||
j++
|
||||
}
|
||||
out.WriteString(Paint(S.Success, s[i:j]))
|
||||
out.WriteString(Paint(ilovetui.S.Success, s[i:j]))
|
||||
i = j
|
||||
default:
|
||||
j = i
|
||||
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
|
||||
j++
|
||||
}
|
||||
out.WriteString(Paint(S.Warning, s[i:j]))
|
||||
out.WriteString(Paint(ilovetui.S.Warning, s[i:j]))
|
||||
i = j
|
||||
}
|
||||
}
|
||||
if i < n && s[i] == '>' {
|
||||
out.WriteString(Paint(S.Subtle, ">"))
|
||||
out.WriteString(Paint(ilovetui.S.Subtle, ">"))
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
+19
-77
@@ -1,106 +1,48 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
PagerDotActive string
|
||||
PagerDotInactive string
|
||||
methodGet lipgloss.Style
|
||||
methodPost lipgloss.Style
|
||||
methodPutPatch lipgloss.Style
|
||||
methodDelete lipgloss.Style
|
||||
methodDefault lipgloss.Style
|
||||
}
|
||||
|
||||
var S *Styles
|
||||
|
||||
func Init(cfg *config.Config) {
|
||||
c := cfg.TUI.Colors
|
||||
|
||||
subtleBg := lipgloss.Color("#" + c.Base01) // Lighter Background (status bars)
|
||||
selection := lipgloss.Color("#" + c.Base02) // Selection Background
|
||||
subtle := lipgloss.Color("#" + c.Base03) // Faint text, borders
|
||||
mutedFg := lipgloss.Color("#" + c.Base04) // Muted foreground
|
||||
text := lipgloss.Color("#" + c.Base05) // Default Foreground
|
||||
errCol := lipgloss.Color("#" + c.Base08) // Red: errors
|
||||
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
|
||||
success := lipgloss.Color("#" + c.Base0B) // Green: success
|
||||
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
|
||||
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
|
||||
|
||||
func Init() {
|
||||
methodBase := lipgloss.NewStyle().Bold(true).Width(7)
|
||||
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().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(purple),
|
||||
BorderForeground(ilovetui.S.Base0E),
|
||||
|
||||
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
|
||||
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
|
||||
methodGet: methodBase.Foreground(ilovetui.S.Success),
|
||||
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 {
|
||||
base := lipgloss.NewStyle().Bold(true).Width(7)
|
||||
switch method {
|
||||
case "GET":
|
||||
return base.Foreground(s.Success)
|
||||
return s.methodGet
|
||||
case "POST":
|
||||
return base.Foreground(s.Warning)
|
||||
return s.methodPost
|
||||
case "PUT", "PATCH":
|
||||
return base.Foreground(s.Primary)
|
||||
return s.methodPutPatch
|
||||
case "DELETE":
|
||||
return base.Foreground(s.Error)
|
||||
return s.methodDelete
|
||||
default:
|
||||
return base.Foreground(s.Text)
|
||||
return s.methodDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ type Model struct {
|
||||
database *db.DB
|
||||
logFile *os.File
|
||||
pluginManager *plugins.Manager
|
||||
fatalErr error
|
||||
|
||||
width int
|
||||
height int
|
||||
@@ -106,6 +107,20 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
m.findingsPage.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)
|
||||
if err := mgr.LoadFromDir(pluginsDir); err != nil {
|
||||
log.Printf("plugins: %v", err)
|
||||
@@ -121,6 +136,8 @@ func New(broker *intercept.Broker, name, path string) Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) FatalErr() error { return m.fatalErr }
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
mgr := m.pluginManager
|
||||
return tea.Batch(
|
||||
|
||||
@@ -37,6 +37,8 @@ type pageEntry struct {
|
||||
isEditing func(m *Model) bool
|
||||
// resize propagates a new (w, h) to the page model.
|
||||
resize func(m *Model, w, h int)
|
||||
// hasUpdate reports whether the page has unseen updates.
|
||||
hasUpdate func(m *Model) bool
|
||||
}
|
||||
|
||||
var pageRegistry = []pageEntry{
|
||||
@@ -52,6 +54,7 @@ var pageRegistry = []pageEntry{
|
||||
},
|
||||
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
|
||||
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
|
||||
hasUpdate: func(m *Model) bool { return m.intercept.HasUnread() },
|
||||
},
|
||||
{
|
||||
id: pageHistory,
|
||||
@@ -114,7 +117,8 @@ var pageRegistry = []pageEntry{
|
||||
m.findingsPage = updated.(findingsUI.Model)
|
||||
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,
|
||||
|
||||
+13
-10
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
type sidebarState string
|
||||
@@ -42,7 +42,6 @@ func (m *Model) renderSidebar() string {
|
||||
if m.sidebarState == sidebarHidden {
|
||||
return ""
|
||||
}
|
||||
s := style.S
|
||||
// content width inside bordered panel
|
||||
inner := m.getSidebarWidth() - 2
|
||||
|
||||
@@ -50,22 +49,26 @@ func (m *Model) renderSidebar() string {
|
||||
if m.sidebarState == sidebarCollapsed {
|
||||
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)
|
||||
|
||||
badgeSelected := lipgloss.NewStyle().Foreground(s.Primary).Bold(true)
|
||||
badgeNormal := lipgloss.NewStyle().Foreground(s.Subtle)
|
||||
textSelected := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
textNormal := lipgloss.NewStyle().Foreground(s.Text)
|
||||
badgeSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Bold(true)
|
||||
badgeNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
textSelected := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
textNormal := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
|
||||
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
|
||||
|
||||
var items strings.Builder
|
||||
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
|
||||
|
||||
for i, entry := range sidebarEntries {
|
||||
selected := entry.id == m.page
|
||||
badgeStyle, textStyle := badgeNormal, textNormal
|
||||
if selected {
|
||||
badgeStyle, textStyle = badgeSelected, textSelected
|
||||
} else if entry.hasUpdate != nil && entry.hasUpdate(m) {
|
||||
badgeStyle = badgeUnread
|
||||
}
|
||||
icon := ""
|
||||
if entry.icon != nil {
|
||||
@@ -91,13 +94,13 @@ func (m *Model) renderSidebar() string {
|
||||
}
|
||||
parts := []string{
|
||||
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,
|
||||
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
|
||||
lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render(divider),
|
||||
items.String(),
|
||||
)
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
|
||||
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
||||
return ilovetui.S.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
|
||||
}
|
||||
|
||||
+12
-10
@@ -37,6 +37,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case intercept.RequestArrivedMsg:
|
||||
updated, cmd := m.intercept.Update(msg)
|
||||
m.intercept = updated.(interceptUI.Model)
|
||||
if m.page == pageIntercept {
|
||||
m.intercept.ClearUnread()
|
||||
}
|
||||
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
|
||||
case intercept.ResponseArrivedMsg:
|
||||
updated, cmd := m.intercept.Update(msg)
|
||||
@@ -104,16 +107,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case proxyPkg.ErrMsg:
|
||||
if msg.Err != nil {
|
||||
log.Printf("proxy error: %v", msg.Err)
|
||||
return m, tea.Batch(
|
||||
func() tea.Msg {
|
||||
return notificationsUI.NotificationMsg{
|
||||
Title: "Proxy Error",
|
||||
Body: msg.Err.Error(),
|
||||
Kind: notificationsUI.KindError,
|
||||
}
|
||||
},
|
||||
tea.Quit,
|
||||
)
|
||||
m.fatalErr = msg.Err
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -129,6 +124,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case findingsUI.FindingsLoadedMsg:
|
||||
updated, cmd := m.findingsPage.Update(msg)
|
||||
m.findingsPage = updated.(findingsUI.Model)
|
||||
if m.page == pageFindings {
|
||||
m.findingsPage.ClearUnread()
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case replayUI.SendToReplayMsg:
|
||||
@@ -258,8 +256,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.history.RefreshCmd()
|
||||
}
|
||||
if p == pageFindings {
|
||||
m.findingsPage.ClearUnread()
|
||||
return m, findingsUI.RefreshCmd(m.database)
|
||||
}
|
||||
if p == pageIntercept {
|
||||
m.intercept.ClearUnread()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
@@ -52,5 +52,5 @@ func (m *Model) renderActivePage() string {
|
||||
return e.render(m)
|
||||
}
|
||||
}
|
||||
return style.S.Faint.Render("Work in progress")
|
||||
return ilovetui.S.Faint.Render("Work in progress")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -14,7 +14,6 @@ const (
|
||||
popupH = 20
|
||||
)
|
||||
|
||||
|
||||
type OpenMsg struct {
|
||||
RawRequest string
|
||||
Scheme string
|
||||
@@ -48,20 +47,18 @@ type Model struct {
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
s := style.S
|
||||
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.SetSpacing(0)
|
||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.MutedFg).PaddingLeft(1)
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Muted).PaddingLeft(1)
|
||||
|
||||
l := list.New(allItems, delegate, popupW, 8)
|
||||
l.SetShowTitle(false)
|
||||
@@ -126,7 +123,7 @@ func (m Model) popupHeight() 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 {
|
||||
|
||||
@@ -2,14 +2,12 @@ package copy
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
|
||||
)
|
||||
|
||||
func (m *Model) View(background string) string {
|
||||
s := style.S
|
||||
|
||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
||||
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||
Render(" enter: copy • /: filter • esc: cancel")
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
@@ -19,10 +17,10 @@ func (m *Model) View(background string) string {
|
||||
|
||||
border := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(s.Primary)
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -12,7 +12,6 @@ const (
|
||||
popupH = 20
|
||||
)
|
||||
|
||||
|
||||
type OpenMsg struct {
|
||||
RawRequest string
|
||||
Scheme string
|
||||
@@ -49,20 +48,18 @@ type Model struct {
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
s := style.S
|
||||
|
||||
delegate := list.NewDefaultDelegate()
|
||||
delegate.SetSpacing(0)
|
||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
|
||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
|
||||
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(2)
|
||||
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).PaddingLeft(2)
|
||||
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.Primary).Bold(true).PaddingLeft(1)
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(1)
|
||||
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.MutedFg).PaddingLeft(1)
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Muted).PaddingLeft(1)
|
||||
|
||||
l := list.New(allFormats, delegate, popupW, 8)
|
||||
l.SetShowTitle(false)
|
||||
@@ -117,5 +114,5 @@ func (m Model) popupHeight() int {
|
||||
|
||||
// listHeight = panel content area - hint line (1)
|
||||
func (m Model) listHeight() int {
|
||||
return style.PanelContentH(m.popupHeight()) - 1
|
||||
return ilovetui.ContentHeight(m.popupHeight()) - 1
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func (m *Model) View(background string) string {
|
||||
s := style.S
|
||||
|
||||
hint := lipgloss.NewStyle().Foreground(s.Subtle).
|
||||
hint := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).
|
||||
Render(" enter: copy • /: filter • esc: cancel")
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
@@ -21,17 +19,15 @@ func (m *Model) View(background string) string {
|
||||
|
||||
border := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(s.Primary)
|
||||
BorderForeground(ilovetui.S.Primary)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func OverlayCenter(bg, popup string, w, h int) string {
|
||||
s := style.S
|
||||
|
||||
stripped := ansi.Strip(bg)
|
||||
rawLines := strings.Split(stripped, "\n")
|
||||
bgRunes := make([][]rune, h)
|
||||
@@ -67,7 +63,7 @@ func OverlayCenter(bg, popup string, w, h int) string {
|
||||
startX = 0
|
||||
}
|
||||
|
||||
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
|
||||
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true)
|
||||
|
||||
result := make([]string, h)
|
||||
for y := 0; y < h; y++ {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
@@ -76,7 +76,6 @@ func (m Model) View(background string) string {
|
||||
return background
|
||||
}
|
||||
|
||||
s := style.S
|
||||
const popupW = 34
|
||||
|
||||
var popups []string
|
||||
@@ -89,17 +88,17 @@ func (m Model) View(background string) string {
|
||||
var accent color.Color
|
||||
switch n.kind {
|
||||
case KindSuccess:
|
||||
accent = s.Success
|
||||
accent = ilovetui.S.Success
|
||||
case KindWarning:
|
||||
accent = s.Warning
|
||||
accent = ilovetui.S.Warning
|
||||
case KindError:
|
||||
accent = s.Error
|
||||
accent = ilovetui.S.Error
|
||||
default:
|
||||
accent = s.Primary
|
||||
accent = ilovetui.S.Primary
|
||||
}
|
||||
|
||||
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)
|
||||
box := lipgloss.NewStyle().
|
||||
|
||||
+10
-12
@@ -8,6 +8,7 @@ import (
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"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]
|
||||
}
|
||||
|
||||
s := style.S
|
||||
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
|
||||
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
|
||||
dim := lipgloss.NewStyle().Foreground(s.Subtle)
|
||||
boldErr := lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true)
|
||||
boldOk := lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true)
|
||||
dim := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
|
||||
|
||||
var lb, rb strings.Builder
|
||||
for _, seg := range segs {
|
||||
@@ -212,9 +212,9 @@ type Model struct {
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
leftViewport: style.NewViewport(),
|
||||
rightViewport: style.NewViewport(),
|
||||
help: style.NewHelp(),
|
||||
leftViewport: ilovetui.NewViewport(),
|
||||
rightViewport: ilovetui.NewViewport(),
|
||||
help: ilovetui.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func (m *Model) recalcSizes() {
|
||||
rightInner = 0
|
||||
}
|
||||
|
||||
viewportH := style.PanelContentH(panelH)
|
||||
viewportH := ilovetui.ContentHeight(panelH)
|
||||
|
||||
m.leftViewport.SetWidth(leftInner)
|
||||
m.leftViewport.SetHeight(viewportH)
|
||||
@@ -296,13 +296,11 @@ func hlLines(raw string) []string {
|
||||
}
|
||||
|
||||
func (m *Model) refreshViewports() {
|
||||
s := style.S
|
||||
|
||||
if m.left.raw == "" {
|
||||
placeholder := lipgloss.Place(
|
||||
m.leftViewport.Width(), m.leftViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(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.rightViewport.SetContent("")
|
||||
@@ -314,7 +312,7 @@ func (m *Model) refreshViewports() {
|
||||
placeholder := lipgloss.Place(
|
||||
m.rightViewport.Width(), m.rightViewport.Height(),
|
||||
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)
|
||||
return
|
||||
|
||||
+11
-14
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -26,8 +27,6 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderPanels(panelH int) string {
|
||||
s := style.S
|
||||
|
||||
leftW := m.width / 2
|
||||
rightW := m.width - leftW
|
||||
|
||||
@@ -46,20 +45,20 @@ func (m *Model) renderPanels(panelH int) string {
|
||||
rightTitle = ansi.Truncate(rightTitle, maxW, "…")
|
||||
}
|
||||
|
||||
leftBorder := s.Panel
|
||||
rightBorder := s.Panel
|
||||
leftBorder := ilovetui.S.Panel
|
||||
rightBorder := ilovetui.S.Panel
|
||||
switch m.focus {
|
||||
case bothSlots:
|
||||
leftBorder = s.PanelFocused
|
||||
rightBorder = s.PanelFocused
|
||||
leftBorder = ilovetui.S.PanelFocused
|
||||
rightBorder = ilovetui.S.PanelFocused
|
||||
case leftSlot:
|
||||
leftBorder = s.PanelFocused
|
||||
leftBorder = ilovetui.S.PanelFocused
|
||||
case rightSlot:
|
||||
rightBorder = s.PanelFocused
|
||||
rightBorder = ilovetui.S.PanelFocused
|
||||
}
|
||||
|
||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
||||
left := ilovetui.RenderWithTitle(leftBorder, leftTitle, ilovetui.ViewportView(&m.leftViewport), leftW, panelH)
|
||||
right := ilovetui.RenderWithTitle(rightBorder, rightTitle, ilovetui.ViewportView(&m.rightViewport), rightW, panelH)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||
}
|
||||
@@ -69,12 +68,11 @@ func (m *Model) renderStatusBar() string {
|
||||
}
|
||||
|
||||
func renderLeftLines(lines []diffLine) string {
|
||||
s := style.S
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
switch l.kind {
|
||||
case lineRemoved:
|
||||
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
|
||||
sb.WriteString(style.Paint(ilovetui.S.Error, "- ") + l.text + "\n")
|
||||
case lineAdded:
|
||||
sb.WriteString("\n")
|
||||
default:
|
||||
@@ -85,12 +83,11 @@ func renderLeftLines(lines []diffLine) string {
|
||||
}
|
||||
|
||||
func renderRightLines(lines []diffLine) string {
|
||||
s := style.S
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
switch l.kind {
|
||||
case lineAdded:
|
||||
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
|
||||
sb.WriteString(style.Paint(ilovetui.S.Success, "+ ") + l.text + "\n")
|
||||
case lineRemoved:
|
||||
sb.WriteString("\n")
|
||||
default:
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
func readDoc(name string) string {
|
||||
@@ -58,12 +58,12 @@ func New() Model {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = "/"
|
||||
s := ti.Styles()
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(style.S.Primary)
|
||||
s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
ti.SetStyles(s)
|
||||
|
||||
return Model{
|
||||
viewport: viewport.New(),
|
||||
help: style.NewHelp(),
|
||||
help: ilovetui.NewHelp(),
|
||||
searchInput: ti,
|
||||
}
|
||||
}
|
||||
@@ -182,8 +182,8 @@ func injectHighlightsInLine(ansiLine string, intervals [][]int, currentIdx int)
|
||||
return ansiLine
|
||||
}
|
||||
|
||||
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.SubtleBg))
|
||||
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(style.S.Primary).Foreground(style.S.Text))
|
||||
normalOpen, normalClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.SubtleBg))
|
||||
currentOpen, currentClose := lipglossAnsiCodes(lipgloss.NewStyle().Background(ilovetui.S.Primary).Foreground(ilovetui.S.Text))
|
||||
|
||||
type injection struct {
|
||||
visPos int
|
||||
|
||||
@@ -9,15 +9,15 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func windowStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(style.S.Subtle).
|
||||
BorderForeground(ilovetui.S.Subtle).
|
||||
Padding(0, 0)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (e Model) View() tea.View {
|
||||
countText = fmt.Sprintf("%d/%d", e.matchIndex+1, len(e.matches))
|
||||
}
|
||||
count := lipgloss.NewStyle().Padding(0, 1).
|
||||
Foreground(style.S.MutedFg).
|
||||
Foreground(ilovetui.S.Muted).
|
||||
Render(countText)
|
||||
statusBar = lipgloss.JoinHorizontal(lipgloss.Top, statusBar, count)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (m *Model) renderMarkdown() {
|
||||
|
||||
width := m.viewport.Width() - 2
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
|
||||
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,17 +11,18 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/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/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
database *db.DB
|
||||
findings []db.Finding
|
||||
cursor int
|
||||
database *db.DB
|
||||
findings []db.Finding
|
||||
cursor int
|
||||
hasUnread bool
|
||||
knownCount int
|
||||
|
||||
listViewport viewport.Model
|
||||
bodyViewport viewport.Model
|
||||
@@ -37,15 +38,18 @@ type Model struct {
|
||||
|
||||
func New() Model {
|
||||
return Model{
|
||||
listViewport: style.NewViewport(),
|
||||
bodyViewport: style.NewViewport(),
|
||||
pager: style.NewPaginator(),
|
||||
help: style.NewHelp(),
|
||||
listViewport: ilovetui.NewViewport(),
|
||||
bodyViewport: ilovetui.NewViewport(),
|
||||
pager: ilovetui.NewPaginator(),
|
||||
help: ilovetui.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) HasUnread() bool { return m.hasUnread }
|
||||
func (m *Model) ClearUnread() { m.hasUnread = false; m.knownCount = len(m.findings) }
|
||||
|
||||
func (m *Model) CurrentMarkdown() string {
|
||||
if len(m.findings) == 0 {
|
||||
return ""
|
||||
@@ -71,9 +75,9 @@ func (m *Model) recalcSizes() {
|
||||
m.help.SetWidth(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 {
|
||||
listVH = 0
|
||||
}
|
||||
@@ -84,7 +88,7 @@ func (m *Model) recalcSizes() {
|
||||
m.pager.PerPage = 1
|
||||
}
|
||||
|
||||
bodyVH := style.PanelContentH(bodyH)
|
||||
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||
m.bodyViewport.SetWidth(inner)
|
||||
m.bodyViewport.SetHeight(bodyVH)
|
||||
|
||||
@@ -143,7 +147,7 @@ func (m *Model) refreshBodyScroll(reset bool) {
|
||||
|
||||
func (m *Model) renderMarkdownCached(src string, width int) string {
|
||||
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)
|
||||
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.
|
||||
if m.renderer == nil || m.rendererWidth != width {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
||||
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err == nil {
|
||||
|
||||
@@ -21,6 +21,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
prevID = m.findings[m.cursor].ID
|
||||
}
|
||||
m.findings = msg.Findings
|
||||
if len(m.findings) > m.knownCount {
|
||||
m.hasUnread = true
|
||||
}
|
||||
if m.cursor >= len(m.findings) {
|
||||
m.cursor = max(0, len(m.findings)-1)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
||||
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,
|
||||
m.renderListPanel(m.width, listH),
|
||||
@@ -27,44 +28,35 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
var dots string
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
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 {
|
||||
s := style.S
|
||||
title := "Description"
|
||||
if len(m.findings) > 0 {
|
||||
title = m.findings[m.cursor].Title
|
||||
}
|
||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
||||
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
s := style.S
|
||||
if len(m.findings) == 0 {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
||||
ilovetui.S.Faint.Render(util.CenterLines("(҂◡_◡) ᕤ", "no findings")),
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.findings))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.findings))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, f := range m.findings[start:end] {
|
||||
@@ -82,18 +74,18 @@ func (m *Model) renderList() string {
|
||||
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
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
||||
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
sevStyle.Background(s.Selection).Width(8).Render(f.Severity),
|
||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
|
||||
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.Foreground(s.Subtle).Width(10).Render(ts),
|
||||
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
|
||||
bg.Width(1).Render(""),
|
||||
bg.Bold(true).Width(titleW).Render(f.Title),
|
||||
)
|
||||
@@ -104,9 +96,9 @@ func (m *Model) renderList() string {
|
||||
" ",
|
||||
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))
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
@@ -44,10 +44,10 @@ func New() Model {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = ""
|
||||
return Model{
|
||||
listViewport: style.NewViewport(),
|
||||
bodyViewport: style.NewViewport(),
|
||||
pager: style.NewPaginator(),
|
||||
help: style.NewHelp(),
|
||||
listViewport: ilovetui.NewViewport(),
|
||||
bodyViewport: ilovetui.NewViewport(),
|
||||
pager: ilovetui.NewPaginator(),
|
||||
help: ilovetui.NewHelp(),
|
||||
searchInput: ti,
|
||||
}
|
||||
}
|
||||
@@ -124,14 +124,14 @@ func (m *Model) recalcSizes() {
|
||||
// 2 (padding) + 2 (prefix char + space)
|
||||
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
|
||||
if 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 {
|
||||
listVH = 0
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (m *Model) recalcSizes() {
|
||||
m.pager.PerPage = 1
|
||||
}
|
||||
|
||||
bodyVH := style.PanelContentH(bodyH)
|
||||
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||
m.bodyViewport.SetWidth(inner)
|
||||
m.bodyViewport.SetHeight(bodyVH)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"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):
|
||||
if m.database != nil {
|
||||
if m.searchKind != searchKindOff {
|
||||
hasUnflagged := false
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
m.database.DeleteAllEntries()
|
||||
m.database.DeleteAllExceptFlagged()
|
||||
}
|
||||
}
|
||||
return m, m.clearSearch()
|
||||
@@ -335,7 +346,7 @@ func (m *Model) refreshBody() {
|
||||
}
|
||||
if raw == "" {
|
||||
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
||||
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(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
|
||||
}
|
||||
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
||||
|
||||
+24
-33
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
@@ -17,7 +18,7 @@ func (m Model) View() tea.View {
|
||||
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,
|
||||
m.renderListPanel(m.width, listH),
|
||||
@@ -28,60 +29,56 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
var dots string
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
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 {
|
||||
s := style.S
|
||||
title := icons.I.Request + "Request"
|
||||
if m.focusedPanel == panelResponse {
|
||||
title = icons.I.Response + "Response"
|
||||
}
|
||||
return style.RenderWithTitle(s.Panel, title, m.bodyViewport.View(), m.width, h)
|
||||
return ilovetui.RenderWithTitle(ilovetui.S.Panel, title, ilovetui.ViewportView(&m.bodyViewport), m.width, h)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
s := style.S
|
||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||
escKey := keys.Keys.Global.Escape.Help().Key
|
||||
switch m.searchKind {
|
||||
case searchKindFulltext:
|
||||
filterKey := keys.Keys.History.Filter.Help().Key
|
||||
if m.searchAccepted {
|
||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
||||
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
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 pad.Render(s.Faint.Render(filterKey) + " " + m.searchInput.View())
|
||||
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.searchInput.View())
|
||||
case searchKindSQL:
|
||||
sqlKey := keys.Keys.History.SqlQuery.Help().Key
|
||||
if m.searchAccepted {
|
||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
filterLine := pad.Render(accent.Render(sqlKey) + " " + s.Bold.Render(m.searchInput.Value()) + s.Faint.Render(" "+escKey+" to clear"))
|
||||
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
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 pad.Render(s.Faint.Render(sqlKey) + " " + m.searchInput.View())
|
||||
return pad.Render(ilovetui.S.Faint.Render(sqlKey) + " " + m.searchInput.View())
|
||||
default:
|
||||
return pad.Render(m.help.View(historyKeyMap{width: m.width}))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
s := style.S
|
||||
if m.searchErr != "" {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
lipgloss.NewStyle().Foreground(s.Error).Render(m.searchErr),
|
||||
lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render(m.searchErr),
|
||||
)
|
||||
}
|
||||
if len(m.entries) == 0 {
|
||||
@@ -92,24 +89,18 @@ func (m *Model) renderList() string {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(msg),
|
||||
ilovetui.S.Faint.Render(msg),
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.entries))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, e := range m.entries[start:end] {
|
||||
globalIdx := start + i
|
||||
selected := globalIdx == m.cursor
|
||||
|
||||
selBg := s.Selection
|
||||
selBg := ilovetui.S.Selection
|
||||
w := m.listViewport.Width()
|
||||
|
||||
statusStr := fmt.Sprintf("%3d", e.StatusCode)
|
||||
@@ -121,7 +112,7 @@ func (m *Model) renderList() string {
|
||||
|
||||
ts := e.Timestamp.Format("15:04:05")
|
||||
statusSt := style.StatusStyle(e.StatusCode, 3)
|
||||
flagSt := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
|
||||
var line string
|
||||
if selected {
|
||||
@@ -134,13 +125,13 @@ func (m *Model) renderList() string {
|
||||
}
|
||||
}
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
bg.Foreground(s.Primary).Width(2).Render(flagStr),
|
||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
|
||||
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||
bg.Width(1).Render(""),
|
||||
statusSt.Background(selBg).Render(statusStr),
|
||||
bg.Width(1).Render(""),
|
||||
bg.Foreground(s.Subtle).Width(10).Render(ts),
|
||||
bg.Foreground(ilovetui.S.Subtle).Width(10).Render(ts),
|
||||
bg.Width(1).Render(""),
|
||||
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
|
||||
)
|
||||
@@ -155,14 +146,14 @@ func (m *Model) renderList() string {
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
" ",
|
||||
flagSt.Width(2).Render(flagStr),
|
||||
s.Method(e.Method).Render(e.Method),
|
||||
style.S.Method(e.Method).Render(e.Method),
|
||||
" ",
|
||||
statusSt.Render(statusStr),
|
||||
" ",
|
||||
s.Faint.Width(10).Render(ts),
|
||||
ilovetui.S.Faint.Width(10).Render(ts),
|
||||
" ",
|
||||
s.Bold.Render(e.Host),
|
||||
s.Faint.Render(e.Path),
|
||||
ilovetui.S.Bold.Render(e.Host),
|
||||
ilovetui.S.Faint.Render(e.Path),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
|
||||
+10
-12
@@ -14,10 +14,10 @@ import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||
)
|
||||
|
||||
@@ -88,19 +88,18 @@ type homeDelegate struct {
|
||||
}
|
||||
|
||||
func newHomeDelegate() homeDelegate {
|
||||
s := style.S
|
||||
leftBorder := lipgloss.Border{Left: "│"}
|
||||
return homeDelegate{
|
||||
normalTitle: lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(4),
|
||||
normalDesc: lipgloss.NewStyle().Foreground(s.Subtle).Faint(true).PaddingLeft(4),
|
||||
normalTitle: lipgloss.NewStyle().Foreground(ilovetui.S.Text).PaddingLeft(4),
|
||||
normalDesc: lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Faint(true).PaddingLeft(4),
|
||||
selectedTitle: lipgloss.NewStyle().
|
||||
Border(leftBorder, false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.Primary).Bold(true).PaddingLeft(3),
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Primary).Bold(true).PaddingLeft(3),
|
||||
selectedDesc: lipgloss.NewStyle().
|
||||
Border(leftBorder, false, false, false, true).
|
||||
BorderForeground(s.Primary).
|
||||
Foreground(s.MutedFg).PaddingLeft(3),
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Foreground(ilovetui.S.Muted).PaddingLeft(3),
|
||||
filterMatch: lipgloss.NewStyle().Underline(true),
|
||||
}
|
||||
}
|
||||
@@ -304,14 +303,13 @@ func buildItems(projects []Project) []list.Item {
|
||||
}
|
||||
|
||||
func (m Model) renderHelpLine() string {
|
||||
s := style.S
|
||||
k := keys.Keys.Home
|
||||
fs := m.list.FilterState()
|
||||
|
||||
kStyle := lipgloss.NewStyle().Foreground(s.MutedFg).Inline(true)
|
||||
dStyle := s.Faint.Inline(true)
|
||||
kStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Muted).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 {
|
||||
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
|
||||
}
|
||||
|
||||
@@ -138,14 +138,14 @@ func sanitizeName(s string) string {
|
||||
}
|
||||
|
||||
func IsValidProjectName(s string) bool {
|
||||
if s == "tmp" {
|
||||
if s == "tmp" || s == "temp" || s == "temporary" {
|
||||
return true
|
||||
}
|
||||
return s != "" && s == sanitizeName(s)
|
||||
}
|
||||
|
||||
func OpenProject(projectDir, name string) (*Project, error) {
|
||||
if name == "tmp" {
|
||||
if name == "tmp" || name == "temp" || name == "temporary" {
|
||||
dir := tempDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,29 +5,28 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
|
||||
)
|
||||
|
||||
const inputPanelMaxW = 44
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
s := style.S
|
||||
iw := m.innerW()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\n")
|
||||
if m.height > teapotMinH {
|
||||
frames := teapot.TeapotFrames()
|
||||
frame := lipgloss.NewStyle().Foreground(s.Primary).Render(frames[m.teapotFrame])
|
||||
frame := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render(frames[m.teapotFrame])
|
||||
sb.WriteString(center(iw, frame))
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(s.Primary).Render("SPILLTEA")))
|
||||
sb.WriteString(center(iw, lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Render("SPILLTEA")))
|
||||
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")
|
||||
|
||||
if m.mode == modeNaming {
|
||||
@@ -50,7 +49,6 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m Model) renderNamingPanel() string {
|
||||
s := style.S
|
||||
iw := m.innerW()
|
||||
|
||||
panelW := inputPanelMaxW
|
||||
@@ -63,15 +61,15 @@ func (m Model) renderNamingPanel() string {
|
||||
innerW := inputPanelInnerW(iw)
|
||||
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().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(s.Primary).
|
||||
BorderForeground(ilovetui.S.Primary).
|
||||
Padding(1, 2).
|
||||
Width(panelW).
|
||||
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
|
||||
sb.WriteString(center(iw, panel))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
@@ -157,20 +158,20 @@ func (m *Model) listHalfWidths() (leftW, rightW int) {
|
||||
func (m *Model) recalcSizes() {
|
||||
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
|
||||
if bodyInner < 0 {
|
||||
bodyInner = 0
|
||||
}
|
||||
bodyVH := style.PanelContentH(bodyH)
|
||||
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||
|
||||
m.textarea.SetWidth(bodyInner)
|
||||
m.textarea.SetHeight(bodyVH)
|
||||
m.bodyViewport.SetWidth(bodyInner)
|
||||
m.bodyViewport.SetHeight(bodyVH)
|
||||
|
||||
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
|
||||
listVH := ilovetui.ContentHeight(listH) - 1 // -1 for the pager dots row
|
||||
if listVH < 0 {
|
||||
listVH = 0
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
func newHelp() help.Model { return style.NewHelp() }
|
||||
|
||||
type interceptKeyMap struct{ width int }
|
||||
|
||||
func iconBinding(b key.Binding, icon string) key.Binding {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/intercept"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
type panel int
|
||||
@@ -30,6 +30,7 @@ type Model struct {
|
||||
|
||||
editing bool
|
||||
interceptEnabled bool
|
||||
hasUnread bool
|
||||
pendingEdits map[*intercept.PendingRequest]string
|
||||
pendingResponseEdits map[*intercept.PendingResponse]string
|
||||
|
||||
@@ -47,14 +48,14 @@ type Model struct {
|
||||
|
||||
func New(broker *intercept.Broker) Model {
|
||||
cfg := config.Global
|
||||
ta := style.NewTextarea(false)
|
||||
ta := ilovetui.NewTextarea(false)
|
||||
ta.Blur()
|
||||
|
||||
lv := style.NewViewport()
|
||||
rv := style.NewViewport()
|
||||
bv := style.NewViewport()
|
||||
p := style.NewPaginator()
|
||||
rp := style.NewPaginator()
|
||||
lv := ilovetui.NewViewport()
|
||||
rv := ilovetui.NewViewport()
|
||||
bv := ilovetui.NewViewport()
|
||||
p := ilovetui.NewPaginator()
|
||||
rp := ilovetui.NewPaginator()
|
||||
|
||||
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
|
||||
|
||||
@@ -68,7 +69,7 @@ func New(broker *intercept.Broker) Model {
|
||||
textarea: ta,
|
||||
pager: p,
|
||||
responsePager: rp,
|
||||
help: newHelp(),
|
||||
help: ilovetui.NewHelp(),
|
||||
pendingEdits: make(map[*intercept.PendingRequest]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) HasUnread() bool { return m.hasUnread }
|
||||
func (m *Model) ClearUnread() { m.hasUnread = false }
|
||||
|
||||
func (m Model) IsEditing() bool { return m.editing }
|
||||
|
||||
func (m Model) IsResponseFocused() bool {
|
||||
|
||||
@@ -31,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
wasEmpty := len(m.queue) == 0
|
||||
m.queue = append(m.queue, msg.Req)
|
||||
m.hasUnread = true
|
||||
m.refreshListViewport()
|
||||
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
|
||||
m.refreshBody()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
||||
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
|
||||
if m.captureResponse {
|
||||
@@ -38,17 +39,15 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
|
||||
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
|
||||
border := s.Panel
|
||||
border := ilovetui.S.Panel
|
||||
if focused {
|
||||
border = s.PanelFocused
|
||||
border = ilovetui.S.PanelFocused
|
||||
}
|
||||
|
||||
var dots string
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
@@ -57,46 +56,42 @@ func (m *Model) renderListPanel(w, h int) string {
|
||||
|
||||
title := icons.I.Request + "Requests"
|
||||
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 {
|
||||
s := style.S
|
||||
|
||||
focused := !m.editing && m.focusedPanel == panelResponses
|
||||
border := s.Panel
|
||||
border := ilovetui.S.Panel
|
||||
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,
|
||||
m.responseViewport.View(),
|
||||
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 {
|
||||
s := style.S
|
||||
|
||||
var body string
|
||||
if m.editing {
|
||||
body = m.textarea.View()
|
||||
} else {
|
||||
body = m.bodyViewport.View()
|
||||
body = ilovetui.ViewportView(&m.bodyViewport)
|
||||
}
|
||||
|
||||
border := s.Panel
|
||||
border := ilovetui.S.Panel
|
||||
if m.editing {
|
||||
border = s.PanelFocused
|
||||
border = ilovetui.S.PanelFocused
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -105,17 +100,10 @@ func (m *Model) renderStatusBar() string {
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
if len(m.queue) == 0 {
|
||||
return lipgloss.Place(m.listViewport.Width(), m.listViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(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 := m.pager.GetSliceBounds(len(m.queue))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.queue))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, req := range m.queue[start:end] {
|
||||
@@ -127,7 +115,7 @@ func (m *Model) renderList() string {
|
||||
}
|
||||
|
||||
selected := globalIdx == m.cursor
|
||||
selBg := s.Selection
|
||||
selBg := ilovetui.S.Selection
|
||||
|
||||
w := m.listViewport.Width()
|
||||
const fixedW = 2 + 7 + 2
|
||||
@@ -140,18 +128,18 @@ func (m *Model) renderList() string {
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(selBg)
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
s.Method(r.Method).Background(selBg).Render(r.Method),
|
||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||
style.S.Method(r.Method).Background(selBg).Render(r.Method),
|
||||
bg.Width(2).Render(""),
|
||||
bg.Bold(true).Width(hostPathW).Render(r.URL.Host+path),
|
||||
)
|
||||
} else {
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
" ",
|
||||
s.Method(r.Method).Render(r.Method),
|
||||
s.Faint.Render(" "),
|
||||
s.Bold.Render(r.URL.Host),
|
||||
s.Faint.Render(path),
|
||||
style.S.Method(r.Method).Render(r.Method),
|
||||
ilovetui.S.Faint.Render(" "),
|
||||
ilovetui.S.Bold.Render(r.URL.Host),
|
||||
ilovetui.S.Faint.Render(path),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
@@ -161,17 +149,10 @@ func (m *Model) renderList() string {
|
||||
|
||||
func (m *Model) renderResponseList() string {
|
||||
if len(m.responseQueue) == 0 {
|
||||
return lipgloss.Place(m.responseViewport.Width(), m.responseViewport.Height(), lipgloss.Center, lipgloss.Center, style.S.Faint.Render(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 := m.responsePager.GetSliceBounds(len(m.responseQueue))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.responsePager, len(m.responseQueue))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, resp := range m.responseQueue[start:end] {
|
||||
@@ -189,7 +170,7 @@ func (m *Model) renderResponseList() string {
|
||||
statusStr := fmt.Sprintf("%d", code)
|
||||
|
||||
selected := globalIdx == m.responseCursor
|
||||
selBg := s.Selection
|
||||
selBg := ilovetui.S.Selection
|
||||
|
||||
statusSt := style.StatusStyle(code, 7)
|
||||
|
||||
@@ -204,7 +185,7 @@ func (m *Model) renderResponseList() string {
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(selBg)
|
||||
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),
|
||||
bg.Width(2).Render(""),
|
||||
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,
|
||||
" ",
|
||||
statusSt.Render(statusStr),
|
||||
s.Faint.Render(" "),
|
||||
s.Bold.Render(f.Request.URL.Host),
|
||||
s.Faint.Render(path),
|
||||
ilovetui.S.Faint.Render(" "),
|
||||
ilovetui.S.Bold.Render(f.Request.URL.Host),
|
||||
ilovetui.S.Faint.Render(path),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/plugins"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -36,7 +36,7 @@ type Model struct {
|
||||
}
|
||||
|
||||
func New(mgr *plugins.Manager) Model {
|
||||
ta := style.NewTextarea(true)
|
||||
ta := ilovetui.NewTextarea(true)
|
||||
ta.Placeholder = "plugin configuration..."
|
||||
ta.Blur()
|
||||
|
||||
@@ -45,12 +45,12 @@ func New(mgr *plugins.Manager) Model {
|
||||
|
||||
return Model{
|
||||
manager: mgr,
|
||||
listViewport: style.NewViewport(),
|
||||
detailViewport: style.NewViewport(),
|
||||
listViewport: ilovetui.NewViewport(),
|
||||
detailViewport: ilovetui.NewViewport(),
|
||||
textarea: ta,
|
||||
filterInput: fi,
|
||||
pager: style.NewPaginator(),
|
||||
help: style.NewHelp(),
|
||||
pager: ilovetui.NewPaginator(),
|
||||
help: ilovetui.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,14 +70,14 @@ func (m *Model) recalcSizes() {
|
||||
}
|
||||
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
|
||||
if 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 {
|
||||
listVH = 0
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (m *Model) recalcSizes() {
|
||||
|
||||
m.filterInput.SetWidth(inner - 2)
|
||||
|
||||
detailContentH := style.PanelContentH(detailH)
|
||||
detailContentH := ilovetui.ContentHeight(detailH)
|
||||
const headerH = 2
|
||||
const configFixedH = 2 // blank line + label line
|
||||
textareaH := max(3, detailContentH/3)
|
||||
|
||||
@@ -59,11 +59,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.textarea.Blur()
|
||||
if info, ok := m.selected(); ok && m.manager != nil {
|
||||
val := m.textarea.Value()
|
||||
m.manager.SaveConfig(info.Name, val)
|
||||
m.manager.SaveConfig(info.ID, val)
|
||||
// Update cached info.
|
||||
m.filtered[m.cursor].ConfigText = val
|
||||
for i := range m.items {
|
||||
if m.items[i].Name == info.Name {
|
||||
if m.items[i].ID == info.ID {
|
||||
m.items[i].ConfigText = val
|
||||
break
|
||||
}
|
||||
@@ -107,10 +107,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, pk.Toggle):
|
||||
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
|
||||
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
|
||||
break
|
||||
}
|
||||
|
||||
+30
-41
@@ -7,19 +7,18 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/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/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
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,
|
||||
m.renderListPanel(m.width, listH),
|
||||
@@ -30,36 +29,34 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
panelStyle := s.PanelFocused
|
||||
panelStyle := ilovetui.S.PanelFocused
|
||||
if m.editing {
|
||||
panelStyle = s.Panel
|
||||
panelStyle = ilovetui.S.Panel
|
||||
}
|
||||
var dots string
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
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 {
|
||||
s := style.S
|
||||
panelStyle := s.Panel
|
||||
panelStyle := ilovetui.S.Panel
|
||||
if m.editing {
|
||||
panelStyle = s.PanelFocused
|
||||
panelStyle = ilovetui.S.PanelFocused
|
||||
}
|
||||
info, ok := m.selected()
|
||||
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 {
|
||||
statusSt = lipgloss.NewStyle().Foreground(s.Success)
|
||||
statusSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||
}
|
||||
status := "disabled"
|
||||
if info.Enabled {
|
||||
@@ -69,26 +66,26 @@ func (m *Model) renderDetailPanel(h int) string {
|
||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
header := pad.Render(
|
||||
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
||||
s.Faint.Render(filepath.Base(info.FilePath)),
|
||||
ilovetui.S.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
|
||||
ilovetui.S.Faint.Render(filepath.Base(info.FilePath)),
|
||||
)
|
||||
|
||||
parts := []string{header, m.detailViewport.View()}
|
||||
parts := []string{header, ilovetui.ViewportView(&m.detailViewport)}
|
||||
|
||||
if m.hasConfig() {
|
||||
var configLabel string
|
||||
if m.editing {
|
||||
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 {
|
||||
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()))
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -100,7 +97,7 @@ func renderPluginDescription(desc string, width int) string {
|
||||
desc = strings.Join(lines, "\n")
|
||||
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
|
||||
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -114,23 +111,21 @@ func renderPluginDescription(desc string, width int) string {
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
s := style.S
|
||||
pad := lipgloss.NewStyle().Padding(0, 1)
|
||||
filterKey := keys.Keys.Plugins.Filter.Help().Key
|
||||
if m.filtering {
|
||||
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
|
||||
}
|
||||
if m.filter != "" {
|
||||
escKey := keys.Keys.Global.Escape.Help().Key
|
||||
accent := lipgloss.NewStyle().Foreground(s.Primary)
|
||||
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
|
||||
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
|
||||
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 pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig(), width: m.width}))
|
||||
}
|
||||
|
||||
func (m *Model) renderList() string {
|
||||
s := style.S
|
||||
if len(m.filtered) == 0 {
|
||||
msg := util.CenterLines("(ง •̀_•́)ง", "no plugins", "", "spilltea --add-default-plugins")
|
||||
if m.filter != "" {
|
||||
@@ -139,27 +134,21 @@ func (m *Model) renderList() string {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
s.Faint.Render(msg),
|
||||
ilovetui.S.Faint.Render(msg),
|
||||
)
|
||||
}
|
||||
|
||||
start, end := m.pager.GetSliceBounds(len(m.filtered))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.filtered))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, p := range m.filtered[start:end] {
|
||||
globalIdx := start + i
|
||||
selected := globalIdx == m.cursor
|
||||
|
||||
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
|
||||
enabledSt := lipgloss.NewStyle().Foreground(ilovetui.S.Error)
|
||||
enabledStr := "off"
|
||||
if p.Enabled {
|
||||
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
|
||||
enabledSt = lipgloss.NewStyle().Foreground(ilovetui.S.Success)
|
||||
enabledStr = "on "
|
||||
}
|
||||
|
||||
@@ -172,10 +161,10 @@ func (m *Model) renderList() string {
|
||||
|
||||
var line string
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(s.Selection)
|
||||
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
|
||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||
enabledSt.Background(ilovetui.S.Selection).Width(3).Render(enabledStr),
|
||||
bg.Width(1).Render(""),
|
||||
bg.Bold(true).Width(nameW).Render(p.Name),
|
||||
)
|
||||
@@ -184,7 +173,7 @@ func (m *Model) renderList() string {
|
||||
" ",
|
||||
enabledSt.Width(3).Render(enabledStr),
|
||||
" ",
|
||||
s.Bold.Render(p.Name),
|
||||
ilovetui.S.Bold.Render(p.Name),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
|
||||
+10
-10
@@ -9,9 +9,9 @@ import (
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
type SendToReplayMsg struct {
|
||||
@@ -61,15 +61,15 @@ type Model struct {
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
ta := style.NewTextarea(false)
|
||||
ta := ilovetui.NewTextarea(false)
|
||||
ta.Blur()
|
||||
return Model{
|
||||
listViewport: style.NewViewport(),
|
||||
requestViewport: style.NewViewport(),
|
||||
responseViewport: style.NewViewport(),
|
||||
listViewport: ilovetui.NewViewport(),
|
||||
requestViewport: ilovetui.NewViewport(),
|
||||
responseViewport: ilovetui.NewViewport(),
|
||||
textarea: ta,
|
||||
pager: style.NewPaginator(),
|
||||
help: style.NewHelp(),
|
||||
pager: ilovetui.NewPaginator(),
|
||||
help: ilovetui.NewHelp(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,13 +149,13 @@ func (m *Model) SetSize(w, h int) {
|
||||
func (m *Model) recalcSizes() {
|
||||
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
|
||||
if 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 {
|
||||
listVH = 0
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func (m *Model) recalcSizes() {
|
||||
if rightInner < 0 {
|
||||
rightInner = 0
|
||||
}
|
||||
bodyVH := style.PanelContentH(bodyH)
|
||||
bodyVH := ilovetui.ContentHeight(bodyH)
|
||||
|
||||
m.requestViewport.SetWidth(leftInner)
|
||||
m.requestViewport.SetHeight(bodyVH)
|
||||
|
||||
@@ -16,12 +16,13 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/andybalholm/brotli"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/config"
|
||||
"github.com/anotherhadi/spilltea/internal/db"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
"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):
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -347,11 +351,11 @@ func (m *Model) refreshBody() {
|
||||
m.requestViewport.SetXOffset(0)
|
||||
|
||||
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 != "" {
|
||||
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
|
||||
} 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.SetXOffset(0)
|
||||
|
||||
+25
-34
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
ilovetui "github.com/anotherhadi/ilovetui"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
"github.com/anotherhadi/spilltea/internal/util"
|
||||
@@ -16,7 +17,7 @@ func (m Model) View() tea.View {
|
||||
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()
|
||||
|
||||
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
@@ -33,45 +34,42 @@ func (m Model) View() tea.View {
|
||||
}
|
||||
|
||||
func (m *Model) renderListPanel(w, h int) string {
|
||||
s := style.S
|
||||
panelStyle := s.Panel
|
||||
panelStyle := ilovetui.S.Panel
|
||||
if !m.editing && m.focusedPanel == panelList {
|
||||
panelStyle = s.PanelFocused
|
||||
panelStyle = ilovetui.S.PanelFocused
|
||||
}
|
||||
var dots string
|
||||
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,
|
||||
m.listViewport.View(),
|
||||
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 {
|
||||
s := style.S
|
||||
var body string
|
||||
border := s.Panel
|
||||
border := ilovetui.S.Panel
|
||||
if m.editing {
|
||||
body = m.textarea.View()
|
||||
border = s.PanelFocused
|
||||
border = ilovetui.S.PanelFocused
|
||||
} else {
|
||||
body = m.requestViewport.View()
|
||||
body = ilovetui.ViewportView(&m.requestViewport)
|
||||
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 {
|
||||
s := style.S
|
||||
border := s.Panel
|
||||
border := ilovetui.S.Panel
|
||||
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 {
|
||||
@@ -83,24 +81,17 @@ func (m *Model) renderList() string {
|
||||
return lipgloss.Place(
|
||||
m.listViewport.Width(), m.listViewport.Height(),
|
||||
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 := m.pager.GetSliceBounds(len(m.entries))
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
start, end := util.PageBounds(m.pager, len(m.entries))
|
||||
|
||||
var sb strings.Builder
|
||||
for i, e := range m.entries[start:end] {
|
||||
globalIdx := start + i
|
||||
selected := globalIdx == m.cursor
|
||||
selBg := s.Selection
|
||||
selBg := ilovetui.S.Selection
|
||||
|
||||
w := m.listViewport.Width()
|
||||
const fixedW = 2 + 7 + 1 + 3 + 1
|
||||
@@ -115,8 +106,8 @@ func (m *Model) renderList() string {
|
||||
if selected {
|
||||
bg := lipgloss.NewStyle().Background(selBg)
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
|
||||
s.Method(e.Method).Background(selBg).Render(e.Method),
|
||||
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
|
||||
style.S.Method(e.Method).Background(selBg).Render(e.Method),
|
||||
bg.Width(1).Render(""),
|
||||
statusSt.Background(selBg).Render(statusStr),
|
||||
bg.Width(1).Render(""),
|
||||
@@ -125,12 +116,12 @@ func (m *Model) renderList() string {
|
||||
} else {
|
||||
line = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
" ",
|
||||
s.Method(e.Method).Render(e.Method),
|
||||
style.S.Method(e.Method).Render(e.Method),
|
||||
" ",
|
||||
statusSt.Render(statusStr),
|
||||
" ",
|
||||
s.Bold.Render(e.Host),
|
||||
s.Faint.Render(e.Path),
|
||||
ilovetui.S.Bold.Render(e.Host),
|
||||
ilovetui.S.Faint.Render(e.Path),
|
||||
)
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
@@ -142,11 +133,11 @@ func entryStatus(e Entry) (string, lipgloss.Style) {
|
||||
base := lipgloss.NewStyle().Bold(true).Width(3)
|
||||
switch {
|
||||
case e.Sending:
|
||||
return "···", base.Foreground(style.S.Subtle)
|
||||
return "···", base.Foreground(ilovetui.S.Subtle)
|
||||
case e.Err != nil:
|
||||
return "ERR", base.Foreground(style.S.Error)
|
||||
return "ERR", base.Foreground(ilovetui.S.Error)
|
||||
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)
|
||||
}
|
||||
|
||||
+22
-3
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
@@ -14,7 +15,7 @@ type EditorFinishedMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func OpenExternalEditor(content string) tea.Cmd {
|
||||
func resolveEditor() string {
|
||||
editor := config.Global.App.ExternalEditor
|
||||
if editor == "" {
|
||||
editor = os.Getenv("EDITOR")
|
||||
@@ -22,15 +23,27 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
return editor
|
||||
}
|
||||
|
||||
func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cmd {
|
||||
f, err := os.CreateTemp("", "spilltea-*.http")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
_, _ = f.WriteString(content)
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
log.Printf("editor: writing temp file: %v", err)
|
||||
}
|
||||
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)
|
||||
return callback(tmpPath, err)
|
||||
})
|
||||
}
|
||||
|
||||
func OpenExternalEditor(content string) tea.Cmd {
|
||||
return openWithEditor(content, func(tmpPath string, err error) tea.Msg {
|
||||
if err != nil {
|
||||
return EditorFinishedMsg{Err: err}
|
||||
}
|
||||
@@ -41,3 +54,9 @@ func OpenExternalEditor(content string) tea.Cmd {
|
||||
return EditorFinishedMsg{Content: string(data)}
|
||||
})
|
||||
}
|
||||
|
||||
func OpenExternalEditorView(content string) tea.Cmd {
|
||||
return openWithEditor(content, func(_ string, _ error) tea.Msg {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package util
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/paginator"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,18 @@ func CenterLines(lines ...string) string {
|
||||
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.
|
||||
func InferScheme(host string) string {
|
||||
if strings.HasSuffix(host, ":80") {
|
||||
|
||||
@@ -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
|
||||
@@ -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='
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+21
-15
@@ -1,28 +1,34 @@
|
||||
Plugin = {
|
||||
name = "Inject Header",
|
||||
description = [[
|
||||
name = "Inject Header",
|
||||
description = [[
|
||||
Inject custom headers into every intercepted request.
|
||||
|
||||
**Config**:
|
||||
- one 'Header-Name: value' per line.
|
||||
```yaml
|
||||
headers:
|
||||
- "X-My-Header: myvalue"
|
||||
```
|
||||
]],
|
||||
on_request = { sync = true },
|
||||
on_request = { sync = true },
|
||||
}
|
||||
|
||||
local headers = {}
|
||||
|
||||
function on_config(config_text)
|
||||
headers = {}
|
||||
for line in config_text:gmatch("[^\n]+") do
|
||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||
if name and value then
|
||||
table.insert(headers, { name = name, value = value })
|
||||
end
|
||||
end
|
||||
function on_config()
|
||||
headers = {}
|
||||
local cfg = get_config()
|
||||
if cfg and cfg.headers then
|
||||
for _, line in ipairs(cfg.headers) do
|
||||
local name, value = line:match("^([^:]+):%s*(.+)$")
|
||||
if name and value then
|
||||
table.insert(headers, { name = name, value = value })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function on_request(req)
|
||||
for _, h in ipairs(headers) do
|
||||
req:set_header(h.name, h.value)
|
||||
end
|
||||
for _, h in ipairs(headers) do
|
||||
req:set_header(h.name, h.value)
|
||||
end
|
||||
end
|
||||
|
||||
+54
-50
@@ -1,70 +1,74 @@
|
||||
Plugin = {
|
||||
name = "IP Filter (Whitelist/Blacklist)",
|
||||
description = [[
|
||||
name = "IP Filter (Whitelist/Blacklist)",
|
||||
description = [[
|
||||
Checks that the proxy's outbound IP is in an allowed list on startup.
|
||||
|
||||
**Config**:
|
||||
- one IP per line
|
||||
- prefix with `!` for a blacklist entry (blocked)
|
||||
- prefix with `#` to comment it out (ignored)
|
||||
- if no IPs are configured, the check is skipped
|
||||
```yaml
|
||||
ips:
|
||||
- "1.2.3.4" # whitelist entry
|
||||
- "!5.6.7.8" # blacklist entry (blocked)
|
||||
```
|
||||
|
||||
- If no IPs are configured, the check is skipped.
|
||||
]],
|
||||
on_start = { sync = false },
|
||||
disable_by_default = true,
|
||||
on_start = { sync = false },
|
||||
disable_by_default = true,
|
||||
}
|
||||
|
||||
local whitelist = {}
|
||||
local blacklist = {}
|
||||
|
||||
function on_config(config_text)
|
||||
whitelist = {}
|
||||
blacklist = {}
|
||||
|
||||
for line in config_text:gmatch("[^\n]+") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||
if trimmed:sub(1, 1) == "!" then
|
||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||
if ip ~= "" then
|
||||
table.insert(blacklist, ip)
|
||||
end
|
||||
else
|
||||
table.insert(whitelist, trimmed)
|
||||
end
|
||||
end
|
||||
end
|
||||
function on_config()
|
||||
whitelist, blacklist = {}, {}
|
||||
local cfg = get_config()
|
||||
if cfg and cfg.ips then
|
||||
for _, entry in ipairs(cfg.ips) do
|
||||
local trimmed = entry:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" then
|
||||
if trimmed:sub(1, 1) == "!" then
|
||||
local ip = trimmed:sub(2):match("^%s*(.-)%s*$")
|
||||
if ip ~= "" then
|
||||
table.insert(blacklist, ip)
|
||||
end
|
||||
else
|
||||
table.insert(whitelist, trimmed)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function on_start()
|
||||
if #whitelist == 0 and #blacklist == 0 then
|
||||
return
|
||||
end
|
||||
if #whitelist == 0 and #blacklist == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||
local result, err = shell_pipe("curl -sf https://api.ipify.org 2>/dev/null")
|
||||
result = result and result:match("^%s*(.-)%s*$") or nil
|
||||
|
||||
if err or not result or result == "" then
|
||||
log("could not determine outbound IP, skipping check")
|
||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||
return
|
||||
end
|
||||
if err or not result or result == "" then
|
||||
log("could not determine outbound IP, skipping check")
|
||||
notif("IP Filter", "Could not determine outbound IP, skipping check", "warning")
|
||||
return
|
||||
end
|
||||
|
||||
for _, ip in ipairs(blacklist) do
|
||||
if result == ip then
|
||||
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, ip in ipairs(blacklist) do
|
||||
if result == ip then
|
||||
notif("IP Filter", "Outbound IP " .. result .. " is blacklisted!", "error")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if #whitelist == 0 then
|
||||
return
|
||||
end
|
||||
if #whitelist == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
for _, ip in ipairs(whitelist) do
|
||||
if result == ip then
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, ip in ipairs(whitelist) do
|
||||
if result == ip then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
|
||||
notif("IP Filter", "Outbound IP " .. result .. " is not in the whitelist!", "error")
|
||||
end
|
||||
|
||||
@@ -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
|
||||
+91
-74
@@ -1,104 +1,121 @@
|
||||
Plugin = {
|
||||
name = "Scopes",
|
||||
description = [[
|
||||
name = "Scopes",
|
||||
description = [[
|
||||
Auto-forward requests and exclude them from history based on patterns.
|
||||
|
||||
**Config**:
|
||||
- `pattern` - whitelist: only intercept matching requests/responses and history entries
|
||||
- `!pattern` - blacklist: skip matching requests/responses and history entries
|
||||
- `r:pattern` - whitelist for requests/responses only (history unaffected)
|
||||
- `r:!pattern` - blacklist for requests/responses only
|
||||
- `h:pattern` - whitelist for history entries only (requests unaffected)
|
||||
- `h:!pattern` - blacklist for history entries only
|
||||
- lines starting with `#` are comments
|
||||
```yaml
|
||||
patterns:
|
||||
- "pattern" # whitelist: only intercept matching requests/responses and history
|
||||
- "!pattern" # blacklist: skip matching requests/responses and history
|
||||
- "r:pattern" # whitelist for requests/responses only
|
||||
- "r:!pattern" # blacklist for requests/responses only
|
||||
- "h:pattern" # whitelist for history only
|
||||
- "h:!pattern" # blacklist for history only
|
||||
```
|
||||
|
||||
Example (ignore static assets):
|
||||
```
|
||||
!%.css$
|
||||
!%.js$
|
||||
!%.png$
|
||||
```yaml
|
||||
patterns:
|
||||
- "!%.css$"
|
||||
- "!%.js$"
|
||||
- "!%.png$"
|
||||
```
|
||||
|
||||
Example (focus on mytarget.com, skip everything else):
|
||||
```
|
||||
mytarget%.com/
|
||||
Example (focus on mytarget.com):
|
||||
```yaml
|
||||
patterns:
|
||||
- "mytarget%.com/"
|
||||
```
|
||||
|
||||
Example (intercept mytarget.com except its static assets):
|
||||
```
|
||||
mytarget%.com/
|
||||
!%.css$
|
||||
!%.js$
|
||||
!%.png$
|
||||
```
|
||||
|
||||
Example (disable history: whitelist never matches any real URL):
|
||||
```
|
||||
h:^$
|
||||
Example (disable history):
|
||||
```yaml
|
||||
patterns:
|
||||
- "h:^$"
|
||||
```
|
||||
]],
|
||||
priority = 100,
|
||||
on_request = { sync = true },
|
||||
on_response = { sync = true },
|
||||
on_history_entry = { sync = true },
|
||||
priority = 100,
|
||||
on_request = { sync = true },
|
||||
on_response = { sync = true },
|
||||
on_history_entry = { sync = true },
|
||||
}
|
||||
|
||||
local blacklist = {}
|
||||
local whitelist = {}
|
||||
local blacklist_req = {}
|
||||
local whitelist_req = {}
|
||||
local blacklist = {}
|
||||
local whitelist = {}
|
||||
local blacklist_req = {}
|
||||
local whitelist_req = {}
|
||||
local blacklist_hist = {}
|
||||
local whitelist_hist = {}
|
||||
|
||||
function on_config(config_text)
|
||||
blacklist, whitelist = {}, {}
|
||||
blacklist_req, whitelist_req = {}, {}
|
||||
blacklist_hist, whitelist_hist = {}, {}
|
||||
for line in config_text:gmatch("[^\n]+") do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||
local scope = trimmed:match("^([rh]):")
|
||||
local rest = scope and trimmed:sub(3) or trimmed
|
||||
local is_bl = rest:sub(1, 1) == "!"
|
||||
local pattern = is_bl and rest:sub(2) or rest
|
||||
if scope == "r" then
|
||||
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||
elseif scope == "h" then
|
||||
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||
else
|
||||
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||
end
|
||||
end
|
||||
end
|
||||
function on_config()
|
||||
blacklist, whitelist = {}, {}
|
||||
blacklist_req, whitelist_req = {}, {}
|
||||
blacklist_hist, whitelist_hist = {}, {}
|
||||
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*$")
|
||||
if trimmed ~= "" and trimmed:sub(1, 1) ~= "#" then
|
||||
local scope = trimmed:match("^([rh]):")
|
||||
local rest = scope and trimmed:sub(3) or trimmed
|
||||
local is_bl = rest:sub(1, 1) == "!"
|
||||
local pattern = is_bl and rest:sub(2) or rest
|
||||
if scope == "r" then
|
||||
table.insert(is_bl and blacklist_req or whitelist_req, pattern)
|
||||
elseif scope == "h" then
|
||||
table.insert(is_bl and blacklist_hist or whitelist_hist, pattern)
|
||||
else
|
||||
table.insert(is_bl and blacklist or whitelist, pattern)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function check_skip(url, bl_extra, wl_extra)
|
||||
for _, p in ipairs(blacklist) do
|
||||
if url:match(p) then return true end
|
||||
end
|
||||
for _, p in ipairs(bl_extra) do
|
||||
if url:match(p) then return true end
|
||||
end
|
||||
local wl = {}
|
||||
for _, p in ipairs(whitelist) do wl[#wl + 1] = p end
|
||||
for _, p in ipairs(wl_extra) do wl[#wl + 1] = p end
|
||||
if #wl > 0 then
|
||||
for _, p in ipairs(wl) do
|
||||
if url:match(p) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
for _, p in ipairs(blacklist) do
|
||||
if url:match(p) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
for _, p in ipairs(bl_extra) do
|
||||
if url:match(p) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
local wl = {}
|
||||
for _, p in ipairs(whitelist) do
|
||||
wl[#wl + 1] = p
|
||||
end
|
||||
for _, p in ipairs(wl_extra) do
|
||||
wl[#wl + 1] = p
|
||||
end
|
||||
if #wl > 0 then
|
||||
for _, p in ipairs(wl) do
|
||||
if url:match(p) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function on_request(req)
|
||||
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
||||
if check_skip(req.url, blacklist_req, whitelist_req) then
|
||||
return "forward"
|
||||
end
|
||||
end
|
||||
|
||||
function on_response(req)
|
||||
if check_skip(req.url, blacklist_req, whitelist_req) then return "forward" end
|
||||
if check_skip(req.url, blacklist_req, whitelist_req) then
|
||||
return "forward"
|
||||
end
|
||||
end
|
||||
|
||||
function on_history_entry(entry)
|
||||
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then return "skip" end
|
||||
if check_skip(entry.host .. entry.path, blacklist_hist, whitelist_hist) then
|
||||
return "skip"
|
||||
end
|
||||
end
|
||||
|
||||
+168
-120
@@ -1,70 +1,91 @@
|
||||
Plugin = {
|
||||
name = "Secret Scan",
|
||||
description = [[
|
||||
name = "Secret Scan",
|
||||
description = [[
|
||||
Scans HTML, JavaScript and JSON content (requests and responses) for hardcoded
|
||||
secrets by matching common secret key names followed by a non-trivial value.
|
||||
|
||||
Uses `grep -E` (available on all Unix systems, no extra dependencies).
|
||||
]],
|
||||
on_request = { sync = false },
|
||||
on_response = { sync = false },
|
||||
disable_by_default = true,
|
||||
on_request = { sync = false },
|
||||
on_response = { sync = false },
|
||||
disable_by_default = true,
|
||||
}
|
||||
|
||||
local CONTENT_TYPES = {
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"text/html",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
}
|
||||
|
||||
-- Key name alternation (case-insensitive via grep -i)
|
||||
-- Suffixes are required (no bare generic keyword alone).
|
||||
local KEYS = {
|
||||
"access(_key|_token)", "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))",
|
||||
"ansible_vault_password", "aos_key",
|
||||
"api(_key|_secret|_token)",
|
||||
"app_(id|key|secret)", "application(_key|_id|_secret)",
|
||||
"auth(_token|_secret|orization)", "authkey", "authsecret",
|
||||
"bearer_?token",
|
||||
"bucket(_password|_key)",
|
||||
"cert_?pass(word)?", "certificate_password",
|
||||
"client(_id|_secret)",
|
||||
"codecov_token", "consumer_(key|secret)",
|
||||
"connection_?string", "credentials?", "crypt(_key|_secret)",
|
||||
"db_(password|passwd|user(name)?)",
|
||||
"deploy(_key|_password|_token)",
|
||||
"docker_?pass(word)?", "dockerhub_?password",
|
||||
"encryption_(key|password)",
|
||||
"jwt_secret", "json_web_token",
|
||||
"keycloak_secret", "kubernetes_token",
|
||||
"ldap_(password|bindpw)", "login(_password|_token)",
|
||||
"mail_?password", "mail_smtp_pass",
|
||||
"mysql_password", "mongo_password",
|
||||
"netlify_token", "npm(_token|_auth_token)",
|
||||
"oauth(_token|_secret)",
|
||||
"openai_(api_key|secret)",
|
||||
"pass(word)?", "passwd",
|
||||
"private(_key|_token)",
|
||||
"rds_password",
|
||||
"s3(_key|_secret|_access_key_id)",
|
||||
"secret(_key|_token|_id)", "security_token",
|
||||
"sendgrid_api_key",
|
||||
"ses_(smtp|access|secret)",
|
||||
"service(_account|_key|_token)",
|
||||
"smtp_pass(word)?", "smtp_secret",
|
||||
"sonar_token",
|
||||
"ssh(_key|_private_key|_rsa)",
|
||||
"supabase(_anon|_service)?_key",
|
||||
"symfony_secret",
|
||||
"telegram_bot_token",
|
||||
"token",
|
||||
"travis_token",
|
||||
"vault(_token|_secret)",
|
||||
"webhook(_secret|_token)",
|
||||
"zapier_webhook_token",
|
||||
"access(_key|_token)",
|
||||
"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))",
|
||||
"ansible_vault_password",
|
||||
"aos_key",
|
||||
"api(_key|_secret|_token)",
|
||||
"app_(id|key|secret)",
|
||||
"application(_key|_id|_secret)",
|
||||
"auth(_token|_secret|orization)",
|
||||
"authkey",
|
||||
"authsecret",
|
||||
"bearer_?token",
|
||||
"bucket(_password|_key)",
|
||||
"cert_?pass(word)?",
|
||||
"certificate_password",
|
||||
"client(_id|_secret)",
|
||||
"codecov_token",
|
||||
"consumer_(key|secret)",
|
||||
"connection_?string",
|
||||
"credentials?",
|
||||
"crypt(_key|_secret)",
|
||||
"db_(password|passwd|user(name)?)",
|
||||
"deploy(_key|_password|_token)",
|
||||
"docker_?pass(word)?",
|
||||
"dockerhub_?password",
|
||||
"encryption_(key|password)",
|
||||
"jwt_secret",
|
||||
"json_web_token",
|
||||
"keycloak_secret",
|
||||
"kubernetes_token",
|
||||
"ldap_(password|bindpw)",
|
||||
"login(_password|_token)",
|
||||
"mail_?password",
|
||||
"mail_smtp_pass",
|
||||
"mysql_password",
|
||||
"mongo_password",
|
||||
"netlify_token",
|
||||
"npm(_token|_auth_token)",
|
||||
"oauth(_token|_secret)",
|
||||
"openai_(api_key|secret)",
|
||||
"pass(word)?",
|
||||
"passwd",
|
||||
"private(_key|_token)",
|
||||
"rds_password",
|
||||
"s3(_key|_secret|_access_key_id)",
|
||||
"secret(_key|_token|_id)",
|
||||
"security_token",
|
||||
"sendgrid_api_key",
|
||||
"ses_(smtp|access|secret)",
|
||||
"service(_account|_key|_token)",
|
||||
"smtp_pass(word)?",
|
||||
"smtp_secret",
|
||||
"sonar_token",
|
||||
"ssh(_key|_private_key|_rsa)",
|
||||
"supabase(_anon|_service)?_key",
|
||||
"symfony_secret",
|
||||
"telegram_bot_token",
|
||||
"token",
|
||||
"travis_token",
|
||||
"vault(_token|_secret)",
|
||||
"webhook(_secret|_token)",
|
||||
"zapier_webhook_token",
|
||||
}
|
||||
|
||||
-- Built once at load time.
|
||||
@@ -74,93 +95,120 @@ local KEYS = {
|
||||
-- [[:space:]]*[:=] REQUIRED: actual = or : assignment operator
|
||||
-- [[:space:]]*"? optional whitespace + opening quote
|
||||
-- [a-zA-Z0-9+/=_.-]{8,} the secret value, at least 8 chars
|
||||
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
||||
local KEY_PAT = "(" .. table.concat(KEYS, "|") .. ")"
|
||||
local FULL_PAT = KEY_PAT .. '[a-z0-9._-]{0,20}[^=:a-zA-Z0-9_]{0,3}[[:space:]]*[:=][[:space:]]*"?[a-zA-Z0-9+/=_.-]{8,}'
|
||||
local GREP_CMD = "grep -Eoni '" .. FULL_PAT .. "'"
|
||||
|
||||
local function is_relevant(ct)
|
||||
if not ct or ct == "" then return false end
|
||||
ct = ct:lower()
|
||||
for _, t in ipairs(CONTENT_TYPES) do
|
||||
if ct:find(t, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
if not ct or ct == "" then
|
||||
return false
|
||||
end
|
||||
ct = ct:lower()
|
||||
for _, t in ipairs(CONTENT_TYPES) do
|
||||
if ct:find(t, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function build_context(lines, linenum)
|
||||
local lo = math.max(1, linenum - 6)
|
||||
local hi = math.min(#lines, linenum + 6)
|
||||
local lo = math.max(1, linenum - 6)
|
||||
local hi = math.min(#lines, linenum + 6)
|
||||
|
||||
local before, after = {}, {}
|
||||
for i = lo, linenum - 1 do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||
table.insert(before, l)
|
||||
end
|
||||
for i = linenum + 1, hi do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then l = l:sub(1, 120) .. "..." end
|
||||
table.insert(after, l)
|
||||
end
|
||||
local before, after = {}, {}
|
||||
for i = lo, linenum - 1 do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then
|
||||
l = l:sub(1, 120) .. "..."
|
||||
end
|
||||
table.insert(before, l)
|
||||
end
|
||||
for i = linenum + 1, hi do
|
||||
local l = lines[i] or ""
|
||||
if #l > 120 then
|
||||
l = l:sub(1, 120) .. "..."
|
||||
end
|
||||
table.insert(after, l)
|
||||
end
|
||||
|
||||
local matched_line = lines[linenum] or ""
|
||||
if #matched_line > 200 then matched_line = matched_line:sub(1, 200) .. "..." end
|
||||
local matched_line = lines[linenum] or ""
|
||||
if #matched_line > 200 then
|
||||
matched_line = matched_line:sub(1, 200) .. "..."
|
||||
end
|
||||
|
||||
local parts = {}
|
||||
if #before > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||
end
|
||||
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||
if #after > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||
end
|
||||
return table.concat(parts, "\n\n")
|
||||
local parts = {}
|
||||
if #before > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(before, "\n") .. "\n```")
|
||||
end
|
||||
table.insert(parts, "> **`" .. matched_line .. "`**")
|
||||
if #after > 0 then
|
||||
table.insert(parts, "```\n" .. table.concat(after, "\n") .. "\n```")
|
||||
end
|
||||
return table.concat(parts, "\n\n")
|
||||
end
|
||||
|
||||
local function scan(label, ct, body, host, path)
|
||||
if not is_relevant(ct) then return end
|
||||
if not body or body == "" then return end
|
||||
if not is_relevant(ct) then
|
||||
return
|
||||
end
|
||||
if not body or body == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local out, err = shell_pipe(GREP_CMD, body)
|
||||
if err and err ~= "" then
|
||||
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||
return
|
||||
end
|
||||
if not out or out == "" then return end
|
||||
local out, err = shell_pipe(GREP_CMD, body)
|
||||
if err and err ~= "" then
|
||||
log("grep error on " .. label .. " for " .. host .. path .. ": " .. err)
|
||||
return
|
||||
end
|
||||
if not out or out == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
local lines = {}
|
||||
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
for entry in out:gmatch("[^\n]+") do
|
||||
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||
if linenum_str then
|
||||
local linenum = tonumber(linenum_str)
|
||||
matched = matched:match("^%s*(.-)%s*$")
|
||||
if matched ~= "" then
|
||||
local display = matched
|
||||
if #display > 200 then display = display:sub(1, 200) .. "..." end
|
||||
local ctx = build_context(lines, linenum)
|
||||
create_finding({
|
||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n**Match:** `" .. display .. "`\n\n" .. ctx,
|
||||
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||
severity = "high",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
for entry in out:gmatch("[^\n]+") do
|
||||
local linenum_str, matched = entry:match("^(%d+):(.+)$")
|
||||
if linenum_str then
|
||||
local linenum = tonumber(linenum_str)
|
||||
matched = matched:match("^%s*(.-)%s*$")
|
||||
if matched ~= "" then
|
||||
local display = matched
|
||||
if #display > 200 then
|
||||
display = display:sub(1, 200) .. "..."
|
||||
end
|
||||
local ctx = build_context(lines, linenum)
|
||||
create_finding({
|
||||
title = "Potential secret in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `"
|
||||
.. host
|
||||
.. "`\n"
|
||||
.. "\n**Path:** `"
|
||||
.. path
|
||||
.. "`\n"
|
||||
.. "\n**Match:** `"
|
||||
.. display
|
||||
.. "`\n\n"
|
||||
.. ctx,
|
||||
key = host .. "|" .. path .. "|" .. label .. "|" .. matched,
|
||||
severity = "high",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function on_request(req)
|
||||
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
||||
scan("request", req.headers["Content-Type"] or "", req:get_body(), req.host, req.path)
|
||||
end
|
||||
|
||||
function on_response(req, res)
|
||||
local ct = ""
|
||||
if res.headers then
|
||||
ct = res.headers["Content-Type"] or ""
|
||||
end
|
||||
scan("response", ct, res:get_body(), req.host, req.path)
|
||||
local ct = ""
|
||||
if res.headers then
|
||||
ct = res.headers["Content-Type"] or ""
|
||||
end
|
||||
scan("response", ct, res:get_body(), req.host, req.path)
|
||||
end
|
||||
|
||||
+51
-45
@@ -1,63 +1,69 @@
|
||||
Plugin = {
|
||||
name = "TruffleHog",
|
||||
description = [[
|
||||
name = "TruffleHog",
|
||||
description = [[
|
||||
Scans request and response bodies for secrets using [TruffleHog](https://github.com/trufflesecurity/trufflehog).
|
||||
|
||||
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_request = { sync = false },
|
||||
on_response = { sync = false },
|
||||
disable_by_default = true,
|
||||
on_start = { sync = false },
|
||||
on_request = { sync = false },
|
||||
on_response = { sync = false },
|
||||
disable_by_default = true,
|
||||
}
|
||||
|
||||
function on_start()
|
||||
local handle = io.popen("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
|
||||
log("trufflehog is not installed or not in PATH")
|
||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||
return false
|
||||
end
|
||||
local result, _ = shell_pipe("command -v trufflehog 2>/dev/null")
|
||||
if not result or result:match("^%s*$") then
|
||||
log("trufflehog is not installed or not in PATH")
|
||||
notif("TruffleHog", "trufflehog is not installed or not in PATH, plugin disabled", "error")
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function scan(label, content, host, path)
|
||||
if not content or content == "" then 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
|
||||
log("trufflehog error on " .. label .. ": " .. err)
|
||||
return
|
||||
end
|
||||
if not out or out == "" then return end
|
||||
local blocks = {}
|
||||
local current = nil
|
||||
for line in out:gmatch("[^\n]+") do
|
||||
if line:match("^Found ") then
|
||||
if current then table.insert(blocks, current) end
|
||||
current = line
|
||||
elseif current then
|
||||
current = current .. "\n" .. line
|
||||
end
|
||||
end
|
||||
if current then table.insert(blocks, current) end
|
||||
for _, block in ipairs(blocks) do
|
||||
create_finding({
|
||||
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `" .. host .. "` \n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
||||
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
||||
severity = "high",
|
||||
})
|
||||
end
|
||||
if not content or content == "" then
|
||||
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
|
||||
log("trufflehog error on " .. label .. ": " .. err)
|
||||
return
|
||||
end
|
||||
if not out or out == "" then
|
||||
return
|
||||
end
|
||||
local blocks = {}
|
||||
local current = nil
|
||||
for line in out:gmatch("[^\n]+") do
|
||||
if line:match("^Found ") then
|
||||
if current then
|
||||
table.insert(blocks, current)
|
||||
end
|
||||
current = line
|
||||
elseif current then
|
||||
current = current .. "\n" .. line
|
||||
end
|
||||
end
|
||||
if current then
|
||||
table.insert(blocks, current)
|
||||
end
|
||||
for _, block in ipairs(blocks) do
|
||||
create_finding({
|
||||
title = "Secret detected in " .. label .. " (" .. host .. ")",
|
||||
description = "**Host:** `" .. host .. "`\n\n**Path:** `" .. path .. "`\n\n```\n" .. block .. "\n```",
|
||||
key = host .. "|" .. path .. "|" .. label .. "|" .. block,
|
||||
severity = "high",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function on_request(req)
|
||||
scan("request", req:get_body(), req.host, req.path)
|
||||
scan("request", req:get_body(), req.host, req.path)
|
||||
end
|
||||
|
||||
function on_response(req, res)
|
||||
scan("response", res:get_body(), req.host, req.path)
|
||||
scan("response", res:get_body(), req.host, req.path)
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
//go:embed docs
|
||||
var DocsFS embed.FS
|
||||
|
||||
//go:embed plugins/*.lua
|
||||
var PluginsFS embed.FS
|
||||
|
||||
Reference in New Issue
Block a user