15 Commits

Author SHA1 Message Date
Hadi a75b65644f v0.0.8
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:18:31 +02:00
Hadi cc0176a73d feat: add "flag" toggle in replay & findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:15:51 +02:00
Hadi ec0fb61099 add filter in ui/replay
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:57:25 +02:00
Hadi ee3e6806c7 fix: jumping cursor on tick when filter is set
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:30:22 +02:00
Hadi 36eab35576 feat: add "Host" header
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:24:40 +02:00
Hadi 8211d87379 fix: overlaping keys in textarea
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:10:33 +02:00
Hadi ef1789af2f edit default keybindings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 11:56:39 +02:00
Hadi 31bb2aa439 v0.0.7
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 01:15:40 +02:00
Hadi b3caa9852c feat: add pre-configured librewolf browser
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 01:12:55 +02:00
Hadi 39248e2be6 fix: bad path
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:45:49 +02:00
Hadi 9d0ef598a7 Per-project configuration overwrites
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:14:54 +02:00
Hadi 77b102791f edit docs
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 00:14:35 +02:00
Hadi 2e0ad98a4a remove useless icons
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 22:26:09 +02:00
Hadi b0afb7cb56 add the ssl-insecure flag
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 22:15:58 +02:00
Hadi acee3df1bc fix: add ssl_insecure, expand values, edit keybindings, ...
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-29 21:59:54 +02:00
41 changed files with 635 additions and 179 deletions
+16 -2
View File
@@ -23,6 +23,7 @@
- [Installation](#installation) - [Installation](#installation)
- [Project Management](#project-management) - [Project Management](#project-management)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Per-project configuration](#per-project-configuration)
- [CLI Flags](#cli-flags) - [CLI Flags](#cli-flags)
- [Plugin System](#plugin-system) - [Plugin System](#plugin-system)
- [Vim / Neovim Integration](#vim--neovim-integration) - [Vim / Neovim Integration](#vim--neovim-integration)
@@ -107,9 +108,9 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
On startup, you choose: On startup, you choose:
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default - **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
- **Existing project**: pick from a list of previous projects - **Existing project**: pick from a list of previous projects
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot! - **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
## Configuration ## Configuration
@@ -118,6 +119,18 @@ Check the default configuration with all the options [here](./internal/config/de
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once. Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
### Per-project configuration
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
Only the keys present in that file are overridden; everything else falls back to the global config.
The priority order is:
1. Global config (`~/.config/spilltea/config.yaml`)
2. Project config (`<project-dir>/config.yaml`)
3. CLI flags (always win)
## CLI Flags ## CLI Flags
``` ```
@@ -135,6 +148,7 @@ Flags:
--plugins-dir string path to plugins dir (overrides config) --plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config) -p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session -P, --project string project name to open directly, or "tmp" for a temporary session
--ssl-insecure skip TLS certificate verification (overrides config)
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config) --upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version version for spilltea -v, --version version for spilltea
``` ```
+19 -4
View File
@@ -38,6 +38,7 @@ func main() {
flagPort int flagPort int
flagUpstreamProxy string flagUpstreamProxy string
flagProject string flagProject string
flagSslInsecure bool
flagAddDefaultPlugins bool flagAddDefaultPlugins bool
flagAddDefaultConfig bool flagAddDefaultConfig bool
) )
@@ -98,18 +99,26 @@ func main() {
} }
config.Global.Version = version config.Global.Version = version
sslInsecureChanged := cmd.Flags().Changed("ssl-insecure")
applyCLI := func(c *config.Config) {
if flagPluginsDir != "" { if flagPluginsDir != "" {
config.Global.App.PluginsDir = flagPluginsDir c.App.PluginsDir = flagPluginsDir
} }
if flagHost != "" { if flagHost != "" {
config.Global.App.Host = flagHost c.App.Host = flagHost
} }
if flagPort != 0 { if flagPort != 0 {
config.Global.App.Port = flagPort c.App.Port = flagPort
} }
if flagUpstreamProxy != "" { if flagUpstreamProxy != "" {
config.Global.App.UpstreamProxy = flagUpstreamProxy c.App.UpstreamProxy = flagUpstreamProxy
} }
if sslInsecureChanged {
c.App.SslInsecure = flagSslInsecure
}
}
applyCLI(config.Global)
config.SetCLIOverrides(applyCLI)
style.Init() style.Init()
icons.Init(config.Global) icons.Init(config.Global)
@@ -122,6 +131,11 @@ func main() {
if err != nil { if err != nil {
return fmt.Errorf("project: %w", err) return fmt.Errorf("project: %w", err)
} }
if err := config.MergeProjectConfig(filepath.Dir(project.Path)); err != nil {
return fmt.Errorf("project config: %w", err)
}
icons.Init(config.Global)
keys.Init(config.Global)
broker := intercept.NewBroker() broker := intercept.NewBroker()
m := appUI.New(broker, project.Name, project.Path) m := appUI.New(broker, project.Name, project.Path)
final, err := tea.NewProgram(m).Run() final, err := tea.NewProgram(m).Run()
@@ -155,6 +169,7 @@ func main() {
rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)") rootCmd.Flags().StringVar(&flagHost, "host", "", "proxy host (overrides config)")
rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)") rootCmd.Flags().IntVarP(&flagPort, "port", "p", 0, "proxy port (overrides config)")
rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)") rootCmd.Flags().StringVar(&flagUpstreamProxy, "upstream-proxy", "", "upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)")
rootCmd.Flags().BoolVar(&flagSslInsecure, "ssl-insecure", false, "skip TLS certificate verification (overrides config)")
rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`) rootCmd.Flags().StringVarP(&flagProject, "project", "P", "", `project name to open directly, or "tmp" for a temporary session`)
rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit") rootCmd.Flags().BoolVar(&flagAddDefaultPlugins, "add-default-plugins", false, "copy built-in example plugins into the plugins dir and exit")
rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit") rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
+11
View File
@@ -1,8 +1,14 @@
package main package main
import ( import (
"log"
"path/filepath"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/intercept" "github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
appUI "github.com/anotherhadi/spilltea/internal/ui/app" appUI "github.com/anotherhadi/spilltea/internal/ui/app"
homeUI "github.com/anotherhadi/spilltea/internal/ui/home" homeUI "github.com/anotherhadi/spilltea/internal/ui/home"
) )
@@ -34,6 +40,11 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.state == rootStateHome { if m.state == rootStateHome {
if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok { if sel, ok := msg.(homeUI.ProjectSelectedMsg); ok {
if err := config.MergeProjectConfig(filepath.Dir(sel.Project.Path)); err != nil {
log.Printf("project config: %v", err)
}
icons.Init(config.Global)
keys.Init(config.Global)
broker := intercept.NewBroker() broker := intercept.NewBroker()
app := appUI.New(broker, sel.Project.Name, sel.Project.Path) app := appUI.New(broker, sel.Project.Name, sel.Project.Path)
m.app = app m.app = app
+15 -2
View File
@@ -4,9 +4,9 @@ Spilltea organizes work into **projects**. Each project maps to a SQLite databas
On startup, you choose: On startup, you choose:
- **New project**: enter a name, stored in `~/.local/share/spilltea/projects/` by default - **New project**: enter a name, stored in `~/.local/share/spilltea/<name>/` by default
- **Existing project**: pick from a list of previous projects - **Existing project**: pick from a list of previous projects
- **Temporary**: no name needed, stored in `/tmp/spilltea/projects/` and will be deleted on your next reboot! - **Temporary**: no name needed, stored in `/tmp/spilltea/<random-id>/` and will be deleted on your next reboot!
## Configuration ## Configuration
@@ -15,6 +15,18 @@ Check the default configuration with all the options [here](./internal/config/de
Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once. Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
### Per-project configuration
You can override any config value on a per-project basis by placing a `config.yaml` file inside the project directory (e.g. `~/.local/share/spilltea/projects/my-project/config.yaml`).
Only the keys present in that file are overridden; everything else falls back to the global config.
The priority order is:
1. Global config (`~/.config/spilltea/config.yaml`)
2. Project config (`<project-dir>/config.yaml`)
3. CLI flags (always win)
## CLI Flags ## CLI Flags
<!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' --> <!-- exec: echo '```' && go run ./cmd/spilltea -h && echo '```' -->
@@ -33,6 +45,7 @@ Flags:
--plugins-dir string path to plugins dir (overrides config) --plugins-dir string path to plugins dir (overrides config)
-p, --port int proxy port (overrides config) -p, --port int proxy port (overrides config)
-P, --project string project name to open directly, or "tmp" for a temporary session -P, --project string project name to open directly, or "tmp" for a temporary session
--ssl-insecure skip TLS certificate verification (overrides config)
--upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config) --upstream-proxy string upstream proxy URL, e.g. http://user:pass@host:8888 (overrides config)
-v, --version version for spilltea -v, --version version for spilltea
``` ```
+2
View File
@@ -11,5 +11,7 @@
# Spilltea Documentation # Spilltea Documentation
- **Version**: `{{.Cfg.Version}}` - **Version**: `{{.Cfg.Version}}`
- **Proxy**: `{{.Cfg.App.Host}}:{{.Cfg.App.Port}}`
- **SSL Insecure**: `{{.Cfg.App.SslInsecure}}`
- **Repository**: `https://github.com/anotherhadi/spilltea` - **Repository**: `https://github.com/anotherhadi/spilltea`
- **Sponsor this project**: `https://ko-fi.com/anotherhadi` - **Sponsor this project**: `https://ko-fi.com/anotherhadi`
+1 -1
View File
@@ -155,7 +155,7 @@ If the user **dismisses** a finding it is permanently hidden and will never reap
## Configuration ## Configuration
Plugin configuration is stored in a `plugins.yaml` file alongside the project database. Plugin configuration is stored in a `plugins.yaml` file alongside the project database and can be edited through the "Proxy" TUI page.
Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML). Each plugin is keyed by its filename (without the `.lua` extension) and has an `enable` toggle and an optional `config` block (arbitrary YAML).
```yaml ```yaml
+7 -1
View File
@@ -1,6 +1,12 @@
## Configuring your browser's proxy settings ## Configuring your browser's proxy settings
We recommend installing **FoxyProxy** to manage your browser's proxies. **If you use Nix**, you can launch a pre-configured browser (LibreWolf) with the proxy and certificate already set up:
```sh
nix run github:anotherhadi/spilltea#browser{{if or (ne .Cfg.App.Host "127.0.0.1") (ne .Cfg.App.Port 8080) (ne .Cfg.App.CertDir "~/.local/share/spilltea")}} --{{end}}{{if ne .Cfg.App.Host "127.0.0.1"}} --host {{.Cfg.App.Host}}{{end}}{{if ne .Cfg.App.Port 8080}} --port {{.Cfg.App.Port}}{{end}}{{if ne .Cfg.App.CertDir "~/.local/share/spilltea"}} --cert-dir {{.Cfg.App.CertDir}}{{end}}
```
Otherwise, we recommend installing **FoxyProxy** to manage your browser's proxies.
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions) You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
1. Open FoxyProxy's options, then click on `Add New Proxy` button. 1. Open FoxyProxy's options, then click on `Add New Proxy` button.
+55 -1
View File
@@ -28,6 +28,7 @@ type Config struct {
ProxyAuth string `mapstructure:"proxy_auth"` ProxyAuth string `mapstructure:"proxy_auth"`
MaxBodySizeMB int `mapstructure:"max_body_size_mb"` MaxBodySizeMB int `mapstructure:"max_body_size_mb"`
ExternalEditor string `mapstructure:"external_editor"` ExternalEditor string `mapstructure:"external_editor"`
SslInsecure bool `mapstructure:"ssl_insecure"`
} `mapstructure:"app"` } `mapstructure:"app"`
TUI struct { TUI struct {
@@ -64,6 +65,43 @@ type GlobalPlugin struct {
var Global *Config var Global *Config
var projectCLIOverrides func(*Config)
// SetCLIOverrides stores a function that re-applies CLI flag overrides.
// It is called automatically after MergeProjectConfig so that CLI flags
// always win over both global and per-project config.
func SetCLIOverrides(fn func(*Config)) {
projectCLIOverrides = fn
}
// MergeProjectConfig merges <projectDir>/config.yaml on top of the current
// Global config, then re-applies any registered CLI overrides.
// If no config.yaml exists in the project directory, this is a no-op.
func MergeProjectConfig(projectDir string) error {
projectConfigPath := filepath.Join(projectDir, "config.yaml")
if _, err := os.Stat(projectConfigPath); errors.Is(err, os.ErrNotExist) {
return nil
}
pv := viper.New()
pv.SetConfigFile(projectConfigPath)
if err := pv.ReadInConfig(); err != nil {
return fmt.Errorf("project config: %w", err)
}
if err := viper.MergeConfigMap(pv.AllSettings()); err != nil {
return fmt.Errorf("merge project config: %w", err)
}
if err := viper.Unmarshal(Global); err != nil {
return err
}
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
if projectCLIOverrides != nil {
projectCLIOverrides(Global)
}
return nil
}
func Load(path string) error { func Load(path string) error {
var defaults map[string]any var defaults map[string]any
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil { if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
@@ -82,7 +120,12 @@ func Load(path string) error {
} }
Global = &Config{} Global = &Config{}
return viper.Unmarshal(Global) if err := viper.Unmarshal(Global); err != nil {
return err
}
Global.App.ProxyAuth = expandEnvValue(Global.App.ProxyAuth)
Global.App.UpstreamProxy = expandEnvValue(Global.App.UpstreamProxy)
return nil
} }
func WriteDefaultConfig(path string) error { func WriteDefaultConfig(path string) error {
@@ -106,6 +149,17 @@ func ExpandPath(p string) string {
return p return p
} }
// expandEnvValue replaces a value starting with "$" with the corresponding
// environment variable, enabling secrets to be kept out of config files.
func expandEnvValue(s string) string {
if len(s) > 1 && s[0] == '$' {
if val := os.Getenv(s[1:]); val != "" {
return val
}
}
return s
}
func flatten(prefix string, m map[string]any) map[string]any { func flatten(prefix string, m map[string]any) map[string]any {
out := make(map[string]any) out := make(map[string]any)
for k, v := range m { for k, v := range m {
+16 -10
View File
@@ -4,15 +4,16 @@ app:
cert_dir: ~/.local/share/spilltea cert_dir: ~/.local/share/spilltea
project_dir: ~/.local/share/spilltea project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins plugins_dir: ~/.config/spilltea/plugins
upstream_proxy: "" # e.g. http://corporate-proxy:8888 or http://user:pass@host:8888 upstream_proxy: "" # e.g. http://corporate-proxy:8888 or $ENV_VAR
proxy_auth: "" # require basic auth to use the proxy, format: user:pass (empty = disabled). Also run: chmod 600 ~/.config/spilltea/config.yaml proxy_auth: "" # require basic auth to use the spilltea proxy, format: user:pass or $ENV_VAR (empty = disabled).
max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB) max_body_size_mb: 50 # max response body size read into memory for large streamed responses (MB)
external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait) external_editor: "" # override $EDITOR for external editing (e.g. nvim, code --wait)
ssl_insecure: false # bypass TLS certificate verification (enable for self-signed cert targets)
intercept: intercept:
default_intercept_enabled: true default_intercept_enabled: true
default_capture_response: false default_capture_response: false
queue_size: 64 # max pending intercepted requests/responses before the proxy blocks queue_size: 128 # max pending intercepted requests/responses before the proxy blocks
auto_forward_regex: auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$' - '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
@@ -31,12 +32,13 @@ tui:
keybindings: keybindings:
global: global:
quit: "q,ctrl+c" quit: "q,ctrl+c"
escape: "esc,ctrl+c"
help: "?" help: "?"
open_logs: "ctrl+g" open_logs: "ctrl+g"
toggle_sidebar: "ctrl+b" toggle_sidebar: "ctrl+b"
cycle_focus: "tab" cycle_focus: "tab"
send_to_replay: "ctrl+r" send_to_replay: "ctrl+r"
send_to_diff: "ctrl+d" send_to_diff: "ctrl+x"
copy_as: "ctrl+y" copy_as: "ctrl+y"
copy: "y" copy: "y"
up: "up,k" up: "up,k"
@@ -45,10 +47,10 @@ keybindings:
right: "right,l" right: "right,l"
goto_top: "g" goto_top: "g"
goto_bottom: "G,end" goto_bottom: "G,end"
scroll_up: "pgup" scroll_up: "pgup,ctrl+u"
scroll_down: "pgdown" scroll_down: "pgdown,ctrl+d"
prev_page: "[" prev_page: "[,ctrl+i"
next_page: "]" next_page: "],ctrl+o"
intercept: intercept:
toggle_intercept: "i" toggle_intercept: "i"
@@ -59,7 +61,7 @@ keybindings:
drop_all: "D" drop_all: "D"
edit: "e,enter" edit: "e,enter"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z" undo_edits: "u,ctrl+z"
history: history:
delete_entry: "x" delete_entry: "x"
@@ -77,15 +79,19 @@ keybindings:
send: "s, enter" send: "s, enter"
edit: "e" edit: "e"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z" undo_edits: "u,ctrl+z"
delete_entry: "x" delete_entry: "x"
delete_all: "X" delete_all: "X"
filter: "/"
flag: "m"
diff: diff:
clear: "x" clear: "x"
findings: findings:
dismiss: "x" dismiss: "x"
dismiss_all: "X"
flag: "m"
plugins: plugins:
toggle: "space" toggle: "space"
+5
View File
@@ -2,6 +2,7 @@ package config
type GlobalKeys struct { type GlobalKeys struct {
Quit string `mapstructure:"quit"` Quit string `mapstructure:"quit"`
Escape string `mapstructure:"escape"`
OpenLogs string `mapstructure:"open_logs"` OpenLogs string `mapstructure:"open_logs"`
ToggleSidebar string `mapstructure:"toggle_sidebar"` ToggleSidebar string `mapstructure:"toggle_sidebar"`
Help string `mapstructure:"help"` Help string `mapstructure:"help"`
@@ -55,6 +56,8 @@ type ReplayKeys struct {
UndoEdits string `mapstructure:"undo_edits"` UndoEdits string `mapstructure:"undo_edits"`
Delete string `mapstructure:"delete_entry"` Delete string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"` DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"`
Flag string `mapstructure:"flag"`
} }
type DiffKeys struct { type DiffKeys struct {
@@ -63,6 +66,8 @@ type DiffKeys struct {
type FindingsKeys struct { type FindingsKeys struct {
Dismiss string `mapstructure:"dismiss"` Dismiss string `mapstructure:"dismiss"`
DismissAll string `mapstructure:"dismiss_all"`
Flag string `mapstructure:"flag"`
} }
type PluginsKeys struct { type PluginsKeys struct {
+3 -1
View File
@@ -79,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries (
request_raw TEXT NOT NULL, request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL, response_raw TEXT NOT NULL,
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL error_msg TEXT NOT NULL,
flagged INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS findings ( CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -89,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info', severity TEXT NOT NULL DEFAULT 'info',
dismissed INTEGER NOT NULL DEFAULT 0, dismissed INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key) UNIQUE(plugin_name, dedup_key)
); );
+25 -2
View File
@@ -11,6 +11,7 @@ type Finding struct {
Title string Title string
Description string Description string
Severity string Severity string
Flagged bool
CreatedAt time.Time CreatedAt time.Time
} }
@@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) { func (d *DB) LoadFindings() ([]Finding, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at `SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at
FROM findings WHERE dismissed = 0 ORDER BY id ASC`, FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
) )
if err != nil { if err != nil {
@@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
for rows.Next() { for rows.Next() {
var f Finding var f Finding
var ts string var ts string
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil { var flagged int
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil {
return nil, err return nil, err
} }
f.Flagged = flagged != 0
for _, layout := range findingTimeFormats { for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil { if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t.Local() f.CreatedAt = t.Local()
@@ -63,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id) _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
return err return err
} }
func (d *DB) ToggleFindingFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
// DismissAllFindings dismisses unflagged findings first. If none are unflagged
// (only flagged ones remain), it dismisses everything.
func (d *DB) DismissAllFindings() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`)
return err
}
+30 -7
View File
@@ -17,16 +17,17 @@ type ReplayEntry struct {
ResponseRaw string ResponseRaw string
StatusCode int StatusCode int
ErrorMsg string ErrorMsg string
Flagged bool
} }
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) { func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg) `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339), e.Timestamp.UTC().Format(time.RFC3339),
e.Scheme, e.Host, e.Path, e.Method, e.Scheme, e.Host, e.Path, e.Method,
e.OriginalRaw, e.RequestRaw, e.ResponseRaw, e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
e.StatusCode, e.ErrorMsg, e.StatusCode, e.ErrorMsg, e.Flagged,
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -36,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
func (d *DB) UpdateReplayEntry(e ReplayEntry) error { func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
_, err := d.conn.Exec( _, err := d.conn.Exec(
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`, `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID, e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID,
) )
return err return err
} }
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged
FROM replay_entries ORDER BY id ASC`, FROM replay_entries ORDER BY id ASC`,
) )
if err != nil { if err != nil {
@@ -56,8 +57,9 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
for rows.Next() { for rows.Next() {
var e ReplayEntry var e ReplayEntry
var ts string var ts string
var flagged int
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method, if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil { &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil {
return nil, err return nil, err
} }
var tsErr error var tsErr error
@@ -65,11 +67,17 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
if tsErr != nil { if tsErr != nil {
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr) log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
} }
e.Flagged = flagged != 0
entries = append(entries, e) entries = append(entries, e)
} }
return entries, rows.Err() return entries, rows.Err()
} }
func (d *DB) ToggleReplayFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
func (d *DB) DeleteReplayEntry(id int64) error { func (d *DB) DeleteReplayEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id) _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
return err return err
@@ -79,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
_, err := d.conn.Exec(`DELETE FROM replay_entries`) _, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err return err
} }
// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If
// none are unflagged (only flagged ones remain), it deletes everything.
func (d *DB) DeleteAllReplayEntriesExceptFlagged() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err
}
+3 -1
View File
@@ -48,6 +48,8 @@ func Init(cfg *config.Config) {
Flag: "󰈻 ", Flag: "󰈻 ",
} }
} else { } else {
I = &Icons{} I = &Icons{
Flag: "*",
}
} }
} }
+3
View File
@@ -14,6 +14,9 @@ func FormatRawRequest(f *proxy.Flow) string {
r := f.Request r := f.Request
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
if r.URL.Host != "" {
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
}
for _, line := range util.SortedHeaderLines(r.Header) { for _, line := range util.SortedHeaderLines(r.Header) {
sb.WriteString(line) sb.WriteString(line)
} }
+5 -1
View File
@@ -7,14 +7,18 @@ import (
type FindingsKeyMap struct { type FindingsKeyMap struct {
Dismiss key.Binding Dismiss key.Binding
DismissAll key.Binding
Flag key.Binding
} }
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap { func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
return FindingsKeyMap{ return FindingsKeyMap{
Dismiss: binding(cfg.Dismiss, "dismiss"), Dismiss: binding(cfg.Dismiss, "dismiss"),
DismissAll: binding(cfg.DismissAll, "dismiss all"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (f FindingsKeyMap) Bindings() []key.Binding { func (f FindingsKeyMap) Bindings() []key.Binding {
return []key.Binding{f.Dismiss} return []key.Binding{f.Flag, f.Dismiss, f.DismissAll}
} }
+1 -1
View File
@@ -41,7 +41,7 @@ func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
CycleFocus: binding(cfg.CycleFocus, "cycle focus"), CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyAs: binding(cfg.CopyAs, "copy as..."), CopyAs: binding(cfg.CopyAs, "copy as..."),
Copy: binding(cfg.Copy, "copy..."), Copy: binding(cfg.Copy, "copy..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), Escape: binding(cfg.Escape, "cancel"),
SendToReplay: binding(cfg.SendToReplay, "send to replay"), SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"), ScrollUp: binding(cfg.ScrollUp, "scroll up"),
ScrollDown: binding(cfg.ScrollDown, "scroll down"), ScrollDown: binding(cfg.ScrollDown, "scroll down"),
+5 -1
View File
@@ -12,6 +12,8 @@ type ReplayKeyMap struct {
UndoEdits key.Binding UndoEdits key.Binding
Delete key.Binding Delete key.Binding
DeleteAll key.Binding DeleteAll key.Binding
Filter key.Binding
Flag key.Binding
} }
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
UndoEdits: binding(cfg.UndoEdits, "undo edits"), UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Delete: binding(cfg.Delete, "delete"), Delete: binding(cfg.Delete, "delete"),
DeleteAll: binding(cfg.DeleteAll, "delete all"), DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (r ReplayKeyMap) Bindings() []key.Binding { func (r ReplayKeyMap) Bindings() []key.Binding {
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll} return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter}
} }
+1
View File
@@ -120,6 +120,7 @@ func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024, StreamLargeBodies: int64(cfg.MaxBodySizeMB) * 1024 * 1024,
CaRootPath: caPath, CaRootPath: caPath,
Upstream: cfg.UpstreamProxy, Upstream: cfg.UpstreamProxy,
SslInsecure: cfg.SslInsecure,
} }
p, err := goproxy.NewProxy(opts) p, err := goproxy.NewProxy(opts)
+20 -7
View File
@@ -36,11 +36,9 @@ func tickCmd() tea.Cmd {
}) })
} }
var sidebarEntries = pageRegistry
var pageShortcuts = func() map[string]page { var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(sidebarEntries)) m := make(map[string]page, len(pageRegistry))
for i, e := range sidebarEntries { for i, e := range pageRegistry {
m[strconv.Itoa(i+1)] = e.id m[strconv.Itoa(i+1)] = e.id
} }
return m return m
@@ -55,6 +53,7 @@ type Model struct {
logFile *os.File logFile *os.File
pluginManager *plugins.Manager pluginManager *plugins.Manager
fatalErr error fatalErr error
logFileErr error
width int width int
height int height int
@@ -96,7 +95,8 @@ func New(broker *intercept.Broker, name, path string) Model {
d, err := db.Open(path) d, err := db.Open(path)
if err != nil { if err != nil {
log.Fatalf("db: %v", err) m.fatalErr = err
return m
} }
m.database = d m.database = d
broker.SetDB(d) broker.SetDB(d)
@@ -129,6 +129,8 @@ func New(broker *intercept.Broker, name, path string) Model {
m.logFile = lf m.logFile = lf
log.SetOutput(lf) log.SetOutput(lf)
logrus.SetOutput(lf) logrus.SetOutput(lf)
} else {
m.logFileErr = err
} }
return m return m
@@ -138,7 +140,7 @@ func (m Model) FatalErr() error { return m.fatalErr }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
mgr := m.pluginManager mgr := m.pluginManager
return tea.Batch( cmds := []tea.Cmd{
intercept.WaitForRequest(m.broker), intercept.WaitForRequest(m.broker),
intercept.WaitForResponse(m.broker), intercept.WaitForResponse(m.broker),
tickCmd(), tickCmd(),
@@ -147,5 +149,16 @@ func (m Model) Init() tea.Cmd {
plugins.WaitForQuit(mgr), plugins.WaitForQuit(mgr),
findingsUI.RefreshCmd(m.database), findingsUI.RefreshCmd(m.database),
func() tea.Msg { mgr.RunOnStart(); return nil }, func() tea.Msg { mgr.RunOnStart(); return nil },
) }
if m.logFileErr != nil {
err := m.logFileErr
cmds = append(cmds, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Warning",
Body: "Could not open log file: " + err.Error(),
Kind: notificationsUI.KindWarning,
}
})
}
return tea.Batch(cmds...)
} }
+1 -1
View File
@@ -62,7 +62,7 @@ func (m *Model) renderSidebar() string {
var items strings.Builder var items strings.Builder
badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true) badgeUnread := lipgloss.NewStyle().Foreground(ilovetui.S.Warning).Bold(true)
for i, entry := range sidebarEntries { for i, entry := range pageRegistry {
selected := entry.id == m.page selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal badgeStyle, textStyle := badgeNormal, textNormal
if selected { if selected {
+2 -11
View File
@@ -2,7 +2,6 @@ package app
import ( import (
"log" "log"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -21,6 +20,7 @@ import (
historyUI "github.com/anotherhadi/spilltea/internal/ui/history" historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept" interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay" replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"github.com/anotherhadi/spilltea/internal/util"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -162,23 +162,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
case tea.KeyPressMsg: case tea.KeyPressMsg:
// ctrl+c always quits, even when a textarea is focused.
if msg.String() == "ctrl+c" {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() { if key.Matches(msg, keys.Keys.Global.Quit) && !m.activeIsEditing() {
m.pluginManager.RunOnQuit() m.pluginManager.RunOnQuit()
return m, tea.Quit return m, tea.Quit
} }
if key.Matches(msg, keys.Keys.Global.OpenLogs) { if key.Matches(msg, keys.Keys.Global.OpenLogs) {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log") logPath := filepath.Join(filepath.Dir(m.projectPath), "logs.log")
return m, tea.ExecProcess(exec.Command(editor, logPath), nil) // #nosec G204 G702 -- editor from trusted $EDITOR env var, logPath is a fixed path return m, tea.ExecProcess(exec.Command(util.ResolveEditor(), logPath), nil) // #nosec G204 G702 -- editor from trusted config/$EDITOR, logPath is a fixed path
} }
if !m.activeIsEditing() { if !m.activeIsEditing() {
+1 -1
View File
@@ -16,7 +16,7 @@ type parsedRequest struct {
path string path string
host string host string
scheme string scheme string
headers []header // garder header{key, value} pour compat locale headers []header
body string body string
} }
+2 -2
View File
@@ -25,10 +25,10 @@ func readDoc(name string) string {
var contentMarkdown = strings.Join([]string{ var contentMarkdown = strings.Join([]string{
readDoc("main.md"), readDoc("main.md"),
readDoc("legal-disclaimer.md"),
readDoc("basics.md"),
readDoc("proxy.md"), readDoc("proxy.md"),
readDoc("certificate.md"), readDoc("certificate.md"),
readDoc("legal-disclaimer.md"),
readDoc("basics.md"),
readDoc("history.md"), readDoc("history.md"),
}, "\n") }, "\n")
+1 -4
View File
@@ -18,12 +18,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg: case tea.KeyPressMsg:
if e.searching { if e.searching {
switch { switch {
case key.Matches(msg, d.SearchReset): case key.Matches(msg, g.Escape):
e.searching = false e.searching = false
e.searchInput.Blur() e.searchInput.Blur()
e.searchInput.SetValue("")
e.matches = nil
e.matchIndex = 0
e.SetSize(e.width, e.height) e.SetSize(e.width, e.height)
case msg.String() == "enter": case msg.String() == "enter":
e.searching = false e.searching = false
+1 -1
View File
@@ -186,7 +186,7 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding { func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
f := keys.Keys.Findings f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help} return []key.Binding{g.Up, g.Down, f.Flag, f.Dismiss, f.DismissAll, g.Copy, g.Help}
} }
func (m findingsKeyMap) FullHelp() [][]key.Binding { func (m findingsKeyMap) FullHelp() [][]key.Binding {
+16
View File
@@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
case key.Matches(msg, f.Flag):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil {
log.Printf("findings: toggle flag: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, f.Dismiss): case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil { if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil { if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
@@ -80,6 +88,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, RefreshCmd(m.database) return m, RefreshCmd(m.database)
} }
case key.Matches(msg, f.DismissAll):
if m.database != nil {
if err := m.database.DismissAllFindings(); err != nil {
log.Printf("findings: dismiss all: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
util.ScrollViewport(&m.bodyViewport, -1) util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
+12 -1
View File
@@ -66,9 +66,10 @@ func (m *Model) renderList() string {
sevStyle := style.SeverityStyle(f.Severity) sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity) sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05") ts := f.CreatedAt.Format("15:04:05")
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
w := m.listViewport.Width() w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1 const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW titleW := w - fixedW
if titleW < 0 { if titleW < 0 {
titleW = 0 titleW = 0
@@ -79,8 +80,13 @@ func (m *Model) renderList() string {
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection) bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity), sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
bg.Width(1).Render(""), bg.Width(1).Render(""),
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)), bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
@@ -90,8 +96,13 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(titleW).Render(f.Title), bg.Bold(true).Width(titleW).Render(f.Title),
) )
} else { } else {
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
sevLabel, sevLabel,
" ", " ",
pluginStr, pluginStr,
+18 -20
View File
@@ -26,7 +26,10 @@ func LoadEntriesCmd(database *db.DB) tea.Cmd {
if database == nil { if database == nil {
return EntriesLoadedMsg{} return EntriesLoadedMsg{}
} }
entries, _ := database.ListEntries() entries, err := database.ListEntries()
if err != nil {
log.Printf("history: load entries: %v", err)
}
return EntriesLoadedMsg{Entries: entries} return EntriesLoadedMsg{Entries: entries}
} }
} }
@@ -73,14 +76,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case SearchResultMsg: case SearchResultMsg:
var selectedID int64
if m.cursor >= 0 && m.cursor < len(m.entries) {
selectedID = m.entries[m.cursor].ID
}
m.entries = msg.Entries m.entries = msg.Entries
m.cursor = 0 m.cursor = 0
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
break
}
}
}
m.searchErr = "" m.searchErr = ""
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
if selectedID == 0 {
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
}
if m.searchKind == searchKindSQL { if m.searchKind == searchKindSQL {
m.acceptSearch() m.acceptSearch()
} }
@@ -102,7 +119,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global g := keys.Keys.Global
if m.searchKind != searchKindOff && !m.searchAccepted { if m.searchKind != searchKindOff && !m.searchAccepted {
// Actively typing: only search navigation + accept/cancel.
switch { switch {
case key.Matches(msg, g.Escape): case key.Matches(msg, g.Escape):
return m, m.clearSearch() return m, m.clearSearch()
@@ -113,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.acceptSearch() m.acceptSearch()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg) m.searchInput, cmd = m.searchInput.Update(msg)
-6
View File
@@ -120,9 +120,6 @@ func (m *Model) renderList() string {
flagStr := " " flagStr := " "
if e.Flagged { if e.Flagged {
flagStr = icons.I.Flag + " " flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
} }
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
@@ -139,9 +136,6 @@ func (m *Model) renderList() string {
flagStr := " " flagStr := " "
if e.Flagged { if e.Flagged {
flagStr = icons.I.Flag + " " flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
} }
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
+3 -2
View File
@@ -318,13 +318,14 @@ func (m Model) renderHelpLine() string {
} }
var parts []string var parts []string
escKey := keys.Keys.Global.Escape.Help().Key
if fs == list.Filtering { if fs == list.Filtering {
parts = append(parts, item("enter", "apply filter")) parts = append(parts, item("enter", "apply filter"))
parts = append(parts, item("esc", "cancel")) parts = append(parts, item(escKey, "cancel"))
} else { } else {
parts = append(parts, item("↑/↓", "navigate")) parts = append(parts, item("↑/↓", "navigate"))
if fs == list.FilterApplied { if fs == list.FilterApplied {
parts = append(parts, item("esc", "clear filter")) parts = append(parts, item(escKey, "clear filter"))
} else { } else {
parts = append(parts, binding(k.Filter)) parts = append(parts, binding(k.Filter))
} }
+3 -1
View File
@@ -6,6 +6,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot" "github.com/anotherhadi/spilltea/internal/ui/components/teapot"
) )
@@ -70,7 +71,8 @@ func (m Model) renderNamingPanel() string {
Width(panelW). Width(panelW).
Render(label + "\n" + inputLine) Render(label + "\n" + inputLine)
hint := ilovetui.S.Faint.Render("[enter] confirm [esc] cancel") escKey := keys.Keys.Global.Escape.Help().Key
hint := ilovetui.S.Faint.Render("[enter] confirm [" + escKey + "] cancel")
var sb strings.Builder var sb strings.Builder
sb.WriteString(center(iw, panel)) sb.WriteString(center(iw, panel))
+6
View File
@@ -28,8 +28,14 @@ func parseRawRequest(content string, req *intercept.PendingRequest) {
if parsed.Proto != "" { if parsed.Proto != "" {
r.Proto = parsed.Proto r.Proto = parsed.Proto
} }
if parsed.Host != "" {
r.URL.Host = parsed.Host
}
r.Header = make(http.Header) r.Header = make(http.Header)
for _, h := range parsed.Headers { for _, h := range parsed.Headers {
if strings.EqualFold(h.Key, "host") {
continue
}
r.Header.Set(h.Key, h.Value) r.Header.Set(h.Key, h.Value)
} }
if parsed.Body != "" { if parsed.Body != "" {
+3 -10
View File
@@ -2,24 +2,17 @@ package intercept
import ( import (
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/keys"
) )
type interceptKeyMap struct{ width int } type interceptKeyMap struct{ width int }
func iconBinding(b key.Binding, icon string) key.Binding {
h := b.Help()
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
}
func (interceptKeyMap) ShortHelp() []key.Binding { func (interceptKeyMap) ShortHelp() []key.Binding {
ic := keys.Keys.Intercept ic := keys.Keys.Intercept
i := icons.I
return []key.Binding{ return []key.Binding{
iconBinding(ic.Forward, i.Forward), ic.Forward,
iconBinding(ic.Drop, i.Drop), ic.Drop,
iconBinding(ic.Edit, i.Edit), ic.Edit,
keys.Keys.Global.Help, keys.Keys.Global.Help,
} }
} }
-15
View File
@@ -287,8 +287,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
} }
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) { func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch { switch {
case key.Matches(msg, keys.Keys.Global.Escape): case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit() m.saveCurrentEdit()
@@ -296,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
m.textarea.Blur() m.textarea.Blur()
m.refreshBodyViewport() m.refreshBodyViewport()
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
}
}
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
+84 -4
View File
@@ -2,11 +2,13 @@ package replay
import ( import (
"fmt" "fmt"
"strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator" "charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
@@ -31,6 +33,7 @@ type Entry struct {
ResponseRaw string // filled after send ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet StatusCode int // 0 = not sent yet
Sending bool Sending bool
Flagged bool
Err error Err error
} }
@@ -43,12 +46,18 @@ const (
) )
type Model struct { type Model struct {
entries []Entry allEntries []Entry
entries []Entry // filtered view; equals allEntries when no filter
entryIndices []int // maps entries[i] -> allEntries[i]; nil when no filter
cursor int cursor int
editing bool editing bool
focusedPanel panel focusedPanel panel
database *db.DB database *db.DB
filterInput textinput.Model
filterActive bool
filterAccepted bool
listViewport viewport.Model listViewport viewport.Model
requestViewport viewport.Model requestViewport viewport.Model
responseViewport viewport.Model responseViewport viewport.Model
@@ -63,11 +72,14 @@ type Model struct {
func New() Model { func New() Model {
ta := ilovetui.NewTextarea(false) ta := ilovetui.NewTextarea(false)
ta.Blur() ta.Blur()
ti := textinput.New()
ti.Prompt = ""
return Model{ return Model{
listViewport: ilovetui.NewViewport(), listViewport: ilovetui.NewViewport(),
requestViewport: ilovetui.NewViewport(), requestViewport: ilovetui.NewViewport(),
responseViewport: ilovetui.NewViewport(), responseViewport: ilovetui.NewViewport(),
textarea: ta, textarea: ta,
filterInput: ti,
pager: ilovetui.NewPaginator(), pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(), help: ilovetui.NewHelp(),
} }
@@ -75,7 +87,7 @@ func New() Model {
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing || (m.filterActive && !m.filterAccepted) }
func (m Model) IsResponseFocused() bool { func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse return m.focusedPanel == panelResponse
@@ -111,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
return return
} }
for _, dbe := range entries { for _, dbe := range entries {
m.entries = append(m.entries, entryFromDB(dbe)) m.allEntries = append(m.allEntries, entryFromDB(dbe))
} }
m.entries = m.allEntries
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
if len(m.entries) > 0 { if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1 m.cursor = len(m.entries) - 1
@@ -136,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
RequestRaw: dbe.RequestRaw, RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw, ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode, StatusCode: dbe.StatusCode,
Flagged: dbe.Flagged,
Err: err, Err: err,
} }
} }
@@ -148,6 +162,7 @@ func (m *Model) SetSize(w, h int) {
func (m *Model) recalcSizes() { func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2) m.help.SetWidth(m.width - 2)
m.filterInput.SetWidth(m.width - 4)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35) listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
@@ -194,12 +209,77 @@ func (m *Model) bodyHalfWidths() (left, right int) {
return return
} }
func (m *Model) currentAllIdx() int {
if m.entryIndices != nil && m.cursor < len(m.entryIndices) {
return m.entryIndices[m.cursor]
}
if m.cursor < len(m.allEntries) {
return m.cursor
}
return -1
}
func matchesFilter(e Entry, term string) bool {
return strings.Contains(strings.ToLower(e.Host), term) ||
strings.Contains(strings.ToLower(e.Path), term) ||
strings.Contains(strings.ToLower(strings.TrimSpace(e.Method)), term)
}
func (m *Model) applyFilter(preferAllIdx int) {
term := strings.ToLower(m.filterInput.Value())
if !m.filterActive || term == "" {
m.entries = m.allEntries
m.entryIndices = nil
if preferAllIdx >= 0 && preferAllIdx < len(m.allEntries) {
m.cursor = preferAllIdx
}
} else {
m.entries = nil
m.entryIndices = nil
newCursor := 0
found := false
for i, e := range m.allEntries {
if matchesFilter(e, term) {
if i == preferAllIdx && !found {
newCursor = len(m.entries)
found = true
}
m.entries = append(m.entries, e)
m.entryIndices = append(m.entryIndices, i)
}
}
if found {
m.cursor = newCursor
} else {
m.cursor = 0
}
}
if len(m.entries) == 0 {
m.cursor = 0
} else if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) clearFilter() {
prevAllIdx := m.currentAllIdx()
m.filterActive = false
m.filterAccepted = false
m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(prevAllIdx)
}
type replayKeyMap struct{ width int } type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding { func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help} return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help}
} }
func (m replayKeyMap) FullHelp() [][]key.Binding { func (m replayKeyMap) FullHelp() [][]key.Binding {
+134 -39
View File
@@ -5,10 +5,14 @@ import (
"compress/gzip" "compress/gzip"
"compress/zlib" "compress/zlib"
"crypto/tls" "crypto/tls"
"crypto/x509"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -63,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
entry.DBID = id entry.DBID = id
} }
} }
m.entries = append(m.entries, entry) m.allEntries = append(m.allEntries, entry)
m.cursor = len(m.entries) - 1 m.applyFilter(len(m.allEntries) - 1)
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
case sentMsg: case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) { allIdx := msg.index
e := &m.entries[msg.index] if allIdx >= 0 && allIdx < len(m.allEntries) {
e := m.allEntries[allIdx]
e.Sending = false e.Sending = false
e.StatusCode = msg.statusCode e.StatusCode = msg.statusCode
e.ResponseRaw = msg.responseRaw e.ResponseRaw = msg.responseRaw
@@ -80,13 +82,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
e.ResponseRaw = "Error: " + msg.err.Error() e.ResponseRaw = "Error: " + msg.err.Error()
} }
if m.database != nil && e.DBID != 0 { if m.database != nil && e.DBID != 0 {
if err := m.database.UpdateReplayEntry(entryToDB(*e)); err != nil { if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil {
log.Printf("replay: update entry: %v", err) log.Printf("replay: update entry: %v", err)
} }
} }
m.allEntries[allIdx] = e
} }
m.refreshListViewport() prevAllIdx := m.currentAllIdx()
m.refreshBody() m.applyFilter(prevAllIdx)
case util.EditorFinishedMsg: case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 { if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
@@ -99,24 +102,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.Button { switch msg.Button {
case tea.MouseWheelUp: case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) { if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollLeft(6) m.scrollBothViewports(true)
m.responseViewport.ScrollLeft(6)
} else { } else {
m.scrollFocusedViewportVertical(-1) m.scrollFocusedViewportVertical(-1)
} }
case tea.MouseWheelDown: case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) { if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6) m.scrollBothViewports(false)
m.responseViewport.ScrollRight(6)
} else { } else {
m.scrollFocusedViewportVertical(1) m.scrollFocusedViewportVertical(1)
} }
case tea.MouseWheelLeft: case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6) m.scrollBothViewports(true)
m.responseViewport.ScrollLeft(6)
case tea.MouseWheelRight: case tea.MouseWheelRight:
m.requestViewport.ScrollRight(6) m.scrollBothViewports(false)
m.responseViewport.ScrollRight(6)
} }
} }
@@ -133,6 +132,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
if m.filterActive && !m.filterAccepted {
switch {
case key.Matches(msg, g.Escape):
m.clearFilter()
case msg.String() == "enter":
m.filterAccepted = true
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
prevAllIdx := m.currentAllIdx()
m.applyFilter(prevAllIdx)
return m, cmd
}
return m, nil
}
if m.filterActive && m.filterAccepted {
if key.Matches(msg, g.Escape) {
m.clearFilter()
return m, nil
}
}
switch { switch {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.focusedPanel == panelList { if m.focusedPanel == panelList {
@@ -168,12 +193,13 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, r.Send): case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending { if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
m.entries[m.cursor].Sending = true allIdx := m.currentAllIdx()
m.entries[m.cursor].ResponseRaw = "" m.allEntries[allIdx].Sending = true
m.entries[m.cursor].Err = nil m.allEntries[allIdx].ResponseRaw = ""
m.refreshListViewport() m.allEntries[allIdx].Err = nil
m.refreshBody() entry := m.allEntries[allIdx]
return m, sendCmd(m.entries[m.cursor], m.cursor) m.applyFilter(allIdx)
return m, sendCmd(entry, allIdx)
} }
case key.Matches(msg, r.Edit): case key.Matches(msg, r.Edit):
@@ -215,34 +241,67 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollRight(6) m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6) m.responseViewport.ScrollRight(6)
case key.Matches(msg, r.Flag):
if len(m.entries) > 0 {
allIdx := m.currentAllIdx()
if m.database != nil && m.allEntries[allIdx].DBID != 0 {
if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil {
log.Printf("replay: toggle flag: %v", err)
}
}
m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged
m.applyFilter(allIdx)
}
case key.Matches(msg, r.Delete): case key.Matches(msg, r.Delete):
if len(m.entries) > 0 { if len(m.entries) > 0 {
e := m.entries[m.cursor] allIdx := m.currentAllIdx()
e := m.allEntries[allIdx]
if m.database != nil && e.DBID != 0 { if m.database != nil && e.DBID != 0 {
if err := m.database.DeleteReplayEntry(e.DBID); err != nil { if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
log.Printf("replay: delete entry: %v", err) log.Printf("replay: delete entry: %v", err)
} }
} }
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...) m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 { preferAllIdx := allIdx
m.cursor-- if preferAllIdx >= len(m.allEntries) {
preferAllIdx = len(m.allEntries) - 1
} }
m.pager.SetTotalPages(len(m.entries)) m.applyFilter(preferAllIdx)
m.refreshListViewport()
m.refreshBody()
} }
case key.Matches(msg, r.DeleteAll): case key.Matches(msg, r.DeleteAll):
if m.database != nil { if m.database != nil {
if err := m.database.DeleteAllReplayEntries(); err != nil { if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil {
log.Printf("replay: delete all entries: %v", err) log.Printf("replay: delete all entries: %v", err)
} }
} }
m.entries = nil // Mirror the DB logic in memory: delete unflagged first; if none, delete all.
hasUnflagged := false
for _, e := range m.allEntries {
if !e.Flagged {
hasUnflagged = true
break
}
}
if hasUnflagged {
filtered := m.allEntries[:0]
for _, e := range m.allEntries {
if e.Flagged {
filtered = append(filtered, e)
}
}
m.allEntries = filtered
} else {
m.allEntries = nil
}
m.cursor = 0 m.cursor = 0
m.pager.SetTotalPages(0) m.filterActive = false
m.refreshListViewport() m.filterAccepted = false
m.refreshBody() m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(-1)
case key.Matches(msg, keys.Keys.Global.GotoTop): case key.Matches(msg, keys.Keys.Global.GotoTop):
m.cursor = 0 m.cursor = 0
@@ -283,6 +342,15 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
} }
} }
case key.Matches(msg, r.Filter):
if m.focusedPanel == panelList {
m.filterActive = true
m.filterAccepted = false
m.filterInput.Placeholder = "filter requests..."
m.filterInput.Focus()
m.recalcSizes()
}
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -327,6 +395,16 @@ func (m *Model) setFocusedViewport(vp viewport.Model) {
} }
} }
func (m *Model) scrollBothViewports(left bool) {
if left {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
}
}
func (m *Model) scrollFocusedViewportVertical(delta int) { func (m *Model) scrollFocusedViewportVertical(delta int) {
vp := m.focusedViewport() vp := m.focusedViewport()
vp.SetYOffset(vp.YOffset() + delta) vp.SetYOffset(vp.YOffset() + delta)
@@ -403,11 +481,27 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
} }
req.Header = headers req.Header = headers
tlsCfg := &tls.Config{InsecureSkipVerify: config.Global.App.SslInsecure} // #nosec G402 -- controlled by ssl_insecure config
if !config.Global.App.SslInsecure {
if certPool, err := x509.SystemCertPool(); err == nil {
caPath := filepath.Join(config.ExpandPath(config.Global.App.CertDir), "mitmproxy-ca-cert.pem")
if pem, err := os.ReadFile(caPath); err == nil {
certPool.AppendCertsFromPEM(pem)
}
tlsCfg.RootCAs = certPool
}
}
transport := &http.Transport{
TLSClientConfig: tlsCfg,
}
if up := config.Global.App.UpstreamProxy; up != "" {
if proxyURL, err := url.Parse(up); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
}
}
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: &http.Transport{ Transport: transport,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 -- intentional for replay feature
},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
@@ -487,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry {
ResponseRaw: e.ResponseRaw, ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode, StatusCode: e.StatusCode,
ErrorMsg: errMsg, ErrorMsg: errMsg,
Flagged: e.Flagged,
} }
} }
+25 -2
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util" "github.com/anotherhadi/spilltea/internal/util"
) )
@@ -73,7 +74,18 @@ func (m *Model) renderResponsePanel(w, h int) string {
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width})) pad := lipgloss.NewStyle().Padding(0, 1)
if m.filterActive {
filterKey := keys.Keys.Replay.Filter.Help().Key
escKey := keys.Keys.Global.Escape.Help().Key
if m.filterAccepted {
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filterInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(replayKeyMap{width: m.width})))
}
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
}
return pad.Render(m.help.View(replayKeyMap{width: m.width}))
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
@@ -94,19 +106,25 @@ func (m *Model) renderList() string {
selBg := ilovetui.S.Selection selBg := ilovetui.S.Selection
w := m.listViewport.Width() w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1 const fixedW = 2 + 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW hostPathW := w - fixedW
if hostPathW < 0 { if hostPathW < 0 {
hostPathW = 0 hostPathW = 0
} }
statusStr, statusSt := entryStatus(e) statusStr, statusSt := entryStatus(e)
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(selBg) bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), 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), style.S.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""), bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr), statusSt.Background(selBg).Render(statusStr),
@@ -114,8 +132,13 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
) )
} else { } else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
style.S.Method(e.Method).Render(e.Method), style.S.Method(e.Method).Render(e.Method),
" ", " ",
statusSt.Render(statusStr), statusSt.Render(statusStr),
+2 -2
View File
@@ -15,7 +15,7 @@ type EditorFinishedMsg struct {
Err error Err error
} }
func resolveEditor() string { func ResolveEditor() string {
editor := config.Global.App.ExternalEditor editor := config.Global.App.ExternalEditor
if editor == "" { if editor == "" {
editor = os.Getenv("EDITOR") editor = os.Getenv("EDITOR")
@@ -39,7 +39,7 @@ func openWithEditor(content string, callback func(string, error) tea.Msg) tea.Cm
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
log.Printf("editor: closing temp file: %v", err) log.Printf("editor: closing temp file: %v", err)
} }
return tea.ExecProcess(exec.Command(resolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp return tea.ExecProcess(exec.Command(ResolveEditor(), tmpPath), func(err error) tea.Msg { // #nosec G204 -- editor from trusted config/$EDITOR, tmpPath from os.CreateTemp
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
return callback(tmpPath, err) return callback(tmpPath, err)
}) })
+61
View File
@@ -0,0 +1,61 @@
{pkgs}: let
defaultCertDir = "$HOME/.local/share/spilltea";
defaultHost = "127.0.0.1";
defaultPort = "8080";
in
pkgs.writeShellApplication {
name = "spilltea-browser";
runtimeInputs = with pkgs; [nss.tools librewolf netcat-gnu procps];
text = ''
CERT_DIR="${defaultCertDir}"
HOST="${defaultHost}"
PORT="${defaultPort}"
while [[ $# -gt 0 ]]; do
case "$1" in
--cert-dir) CERT_DIR="$2"; shift 2 ;;
--host) HOST="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
CERT_FILE="$CERT_DIR/mitmproxy-ca-cert.pem"
if [[ ! -f "$CERT_FILE" ]]; then
echo "Certificate not found at $CERT_FILE"
echo "Launch spilltea first, or use --cert-dir if you changed cert_dir in your config."
exit 1
fi
if ! pgrep -x spilltea > /dev/null 2>&1; then
echo "spilltea is not running. Launch spilltea first."
exit 1
fi
if ! nc -z "$HOST" "$PORT" > /dev/null 2>&1; then
echo "Nothing is listening on $HOST:$PORT."
echo "Is spilltea configured on that port? Use --host and --port if you changed them in your config."
exit 1
fi
PROFILE=$(mktemp -d)
trap 'rm -rf "$PROFILE"' EXIT
certutil -N -d sql:"$PROFILE" --empty-password
certutil -d sql:"$PROFILE" -A -n "spilltea" -t "CT,," -i "$CERT_FILE"
cat > "$PROFILE/user.js" <<EOF
user_pref("network.proxy.type", 1);
user_pref("network.proxy.http", "$HOST");
user_pref("network.proxy.http_port", $PORT);
user_pref("network.proxy.ssl", "$HOST");
user_pref("network.proxy.ssl_port", $PORT);
user_pref("network.proxy.no_proxies_on", "");
user_pref("network.stricttransportsecurity.preloadlist", false);
user_pref("dom.security.https_only_mode", false);
EOF
librewolf --profile "$PROFILE" --no-remote
'';
}
+4 -2
View File
@@ -2,14 +2,15 @@
pkgs, pkgs,
buildGoApplication, buildGoApplication,
}: let }: let
browser = import ./browser.nix {inherit pkgs;};
pname = "spilltea"; pname = "spilltea";
version = "0.0.6"; version = "0.0.8";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication { pkg = buildGoApplication {
inherit pname version ldflags; inherit pname version ldflags;
src = ../.; src = ../.;
modules = ./gomod2nix.toml; modules = ./gomod2nix.toml;
nativeBuildInputs = [ pkgs.installShellFiles ]; nativeBuildInputs = [pkgs.installShellFiles];
env.GOTOOLCHAIN = "local"; env.GOTOOLCHAIN = "local";
postInstall = '' postInstall = ''
installShellCompletion --cmd spilltea \ installShellCompletion --cmd spilltea \
@@ -26,4 +27,5 @@
in { in {
"${pname}" = pkg; "${pname}" = pkg;
default = pkg; default = pkg;
browser = browser;
} }