Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
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"`
}
+101
View File
@@ -0,0 +1,101 @@
package config
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
//go:embed default_config.yaml
var defaultConfig []byte
type Config struct {
Version string `mapstructure:"-"`
App struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
CertDir string `mapstructure:"cert_dir"`
ProjectDir string `mapstructure:"project_dir"`
PluginsDir string `mapstructure:"plugins_dir"`
} `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"`
} `mapstructure:"tui"`
Intercept struct {
DefaultAutoForward bool `mapstructure:"default_auto_forward"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
} `mapstructure:"intercept"`
Replay struct {
SwitchToPageOnSend bool `mapstructure:"switch_to_page_on_send"`
} `mapstructure:"replay"`
History struct {
SkipDuplicates bool `mapstructure:"skip_duplicates"`
} `mapstructure:"history"`
Keybindings Keybindings `mapstructure:"keybindings"`
}
var Global *Config
func Load(path string) error {
var defaults map[string]any
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
return fmt.Errorf("default config: %w", err)
}
for k, v := range flatten("", defaults) {
viper.SetDefault(k, v)
}
viper.SetConfigType("yaml")
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
if !os.IsNotExist(err) {
return err
}
}
Global = &Config{}
return viper.Unmarshal(Global)
}
func ExpandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return p
}
return filepath.Join(home, p[2:])
}
return p
}
func flatten(prefix string, m map[string]any) map[string]any {
out := make(map[string]any)
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nested, ok := v.(map[string]any); ok {
for nk, nv := range flatten(key, nested) {
out[nk] = nv
}
} else {
out[key] = v
}
}
return out
}
+96
View File
@@ -0,0 +1,96 @@
app:
host: 127.0.0.1
port: 8080
cert_dir: ~/.local/share/spilltea
project_dir: ~/.local/share/spilltea
plugins_dir: ~/.config/spilltea/plugins
intercept:
default_auto_forward: false
default_capture_response: false
replay:
switch_to_page_on_send: false
history:
skip_duplicates: false # if true, skip saving entries with the same method, host, path and body
tui:
use_nerdfont_icons: true
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:
quit: "q,ctrl+c"
open_logs: "ctrl+g"
toggle_sidebar: "ctrl+b"
help: "?"
up: "up,k"
down: "down,j"
left: "left,h"
right: "right,l"
cycle_focus: "tab"
copy_request: "ctrl+y"
send_to_replay: "ctrl+r"
scroll_up: "pgup"
scroll_down: "pgdown"
send_to_diff: "ctrl+d"
intercept:
forward: "f"
forward_all: "F"
drop: "d"
drop_all: "D"
auto_forward: "a"
capture_response: "r"
undo_edits: "ctrl+z"
edit: "e,enter"
edit_external: "E"
history:
delete_entry: "x"
delete_all: "X"
sql_query: ":"
filter: "/"
home:
open: "enter,l"
delete: "x"
filter: "/"
replay:
send: "enter,s"
edit: "e"
edit_external: "E"
undo_edits: "R"
delete_entry: "x"
delete_all: "X"
diff:
clear: "c"
findings:
dismiss: "x"
plugins:
toggle: "space"
edit_config: "e,enter"
filter: "/"
+77
View File
@@ -0,0 +1,77 @@
package config
type GlobalKeys struct {
Quit string `mapstructure:"quit"`
OpenLogs string `mapstructure:"open_logs"`
ToggleSidebar string `mapstructure:"toggle_sidebar"`
Help string `mapstructure:"help"`
Up string `mapstructure:"up"`
Down string `mapstructure:"down"`
Left string `mapstructure:"left"`
Right string `mapstructure:"right"`
CycleFocus string `mapstructure:"cycle_focus"`
CopyRequest string `mapstructure:"copy_request"`
SendToReplay string `mapstructure:"send_to_replay"`
ScrollUp string `mapstructure:"scroll_up"`
ScrollDown string `mapstructure:"scroll_down"`
SendToDiff string `mapstructure:"send_to_diff"`
}
type InterceptKeys struct {
Forward string `mapstructure:"forward"`
ForwardAll string `mapstructure:"forward_all"`
Drop string `mapstructure:"drop"`
DropAll string `mapstructure:"drop_all"`
AutoForward string `mapstructure:"auto_forward"`
CaptureResponse string `mapstructure:"capture_response"`
UndoEdits string `mapstructure:"undo_edits"`
Edit string `mapstructure:"edit"`
EditExternal string `mapstructure:"edit_external"`
}
type HistoryKeys struct {
DeleteEntry string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"`
SqlQuery string `mapstructure:"sql_query"`
}
type HomeKeys struct {
Open string `mapstructure:"open"`
Delete string `mapstructure:"delete"`
Filter string `mapstructure:"filter"`
}
type ReplayKeys struct {
Send string `mapstructure:"send"`
Edit string `mapstructure:"edit"`
EditExt string `mapstructure:"edit_external"`
UndoEdits string `mapstructure:"undo_edits"`
Delete string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"`
}
type DiffKeys struct {
Clear string `mapstructure:"clear"`
}
type FindingsKeys struct {
Dismiss string `mapstructure:"dismiss"`
}
type PluginsKeys struct {
Toggle string `mapstructure:"toggle"`
EditConfig string `mapstructure:"edit_config"`
Filter string `mapstructure:"filter"`
}
type Keybindings struct {
Global GlobalKeys `mapstructure:"global"`
Intercept InterceptKeys `mapstructure:"intercept"`
Home HomeKeys `mapstructure:"home"`
History HistoryKeys `mapstructure:"history"`
Replay ReplayKeys `mapstructure:"replay"`
Diff DiffKeys `mapstructure:"diff"`
Findings FindingsKeys `mapstructure:"findings"`
Plugins PluginsKeys `mapstructure:"plugins"`
}
+97
View File
@@ -0,0 +1,97 @@
package db
import (
"database/sql"
_ "modernc.org/sqlite"
)
type DB struct {
conn *sql.DB
}
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
d := &DB{conn: conn}
if err := d.migrate(); err != nil {
conn.Close()
return nil, err
}
return d, nil
}
func (d *DB) migrate() error {
_, err := d.conn.Exec(`
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
method TEXT NOT NULL,
host TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scope (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('whitelist','blacklist')),
pattern TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
scheme TEXT NOT NULL,
host TEXT NOT NULL,
path TEXT NOT NULL,
method TEXT NOT NULL,
original_raw TEXT NOT NULL,
request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL,
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL,
dedup_key TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
dismissed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key)
);
INSERT INTO scope (kind, pattern)
SELECT 'blacklist', '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
WHERE NOT EXISTS (SELECT 1 FROM scope);
`)
return err
}
func (d *DB) Close() error {
if d == nil {
return nil
}
return d.conn.Close()
}
// CountEntriesAt opens the database at path read-only, counts entries, and
// closes it immediately. Safe to call on files not yet opened by the app.
func CountEntriesAt(path string) int {
conn, err := sql.Open("sqlite", path)
if err != nil {
return 0
}
defer conn.Close()
var n int
conn.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n)
return n
}
+131
View File
@@ -0,0 +1,131 @@
package db
import (
"database/sql"
"fmt"
"strings"
"time"
)
type Entry struct {
ID int64
Timestamp time.Time
Method string
Host string
Path string
StatusCode int
RequestRaw string
ResponseRaw string
}
// HasDuplicate returns true if an entry with the same method, host, path and
// request body already exists. Used to implement skip_duplicates filtering.
func (d *DB) HasDuplicate(method, host, path, body string) (bool, error) {
rows, err := d.conn.Query(
`SELECT request_raw FROM entries WHERE method = ? AND host = ? AND path = ?`,
method, host, path,
)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return false, err
}
parts := strings.SplitN(raw, "\n\n", 2)
entryBody := ""
if len(parts) == 2 {
entryBody = parts[1]
}
if entryBody == body {
return true, nil
}
}
return false, rows.Err()
}
func (d *DB) InsertEntry(e Entry) (Entry, error) {
res, err := d.conn.Exec(
`INSERT INTO entries (timestamp, method, host, path, status_code, request_raw, response_raw)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339),
e.Method, e.Host, e.Path, e.StatusCode, e.RequestRaw, e.ResponseRaw,
)
if err != nil {
return e, err
}
e.ID, _ = res.LastInsertId()
return e, nil
}
func scanEntries(rows *sql.Rows) ([]Entry, error) {
var entries []Entry
for rows.Next() {
var e Entry
var ts string
if err := rows.Scan(&e.ID, &ts, &e.Method, &e.Host, &e.Path, &e.StatusCode, &e.RequestRaw, &e.ResponseRaw); err != nil {
return nil, err
}
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
entries = append(entries, e)
}
return entries, rows.Err()
}
func (d *DB) ListEntries() ([]Entry, error) {
rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
FROM entries ORDER BY id DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
func (d *DB) SearchEntries(term string) ([]Entry, error) {
like := "%" + term + "%"
rows, err := d.conn.Query(
`SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw
FROM entries
WHERE method LIKE ? OR host LIKE ? OR path LIKE ? OR request_raw LIKE ? OR response_raw LIKE ?
ORDER BY id DESC`,
like, like, like, like, like,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
// QueryEntries executes a user-supplied query against the entries table.
// If the query does not start with SELECT, it is treated as a WHERE expression
// and wrapped automatically (e.g. "status_code = 404" becomes a full SELECT).
func (d *DB) QueryEntries(rawSQL string) ([]Entry, error) {
q := strings.TrimSpace(rawSQL)
if !strings.HasPrefix(strings.ToUpper(q), "SELECT") {
q = "SELECT id, timestamp, method, host, path, status_code, request_raw, response_raw FROM entries WHERE " + q
} else if strings.ContainsAny(strings.ToUpper(q), "INSERTDELETEUPDATEDROP") {
return nil, fmt.Errorf("only SELECT queries are allowed")
}
rows, err := d.conn.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEntries(rows)
}
func (d *DB) DeleteEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM entries WHERE id = ?`, id)
return err
}
func (d *DB) DeleteAllEntries() error {
_, err := d.conn.Exec(`DELETE FROM entries`)
return err
}
+63
View File
@@ -0,0 +1,63 @@
package db
import "time"
var findingTimeFormats = []string{time.RFC3339, "2006-01-02 15:04:05"}
type Finding struct {
ID int64
PluginName string
DedupKey string
Title string
Description string
Severity string
CreatedAt time.Time
}
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) {
res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
f.PluginName, f.DedupKey, f.Title, f.Description, f.Severity,
f.CreatedAt.UTC().Format(time.RFC3339),
)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
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`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Finding
for rows.Next() {
var f Finding
var ts string
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil {
return nil, err
}
for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t
break
}
}
out = append(out, f)
}
return out, rows.Err()
}
func (d *DB) DismissFinding(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
return err
}
+35
View File
@@ -0,0 +1,35 @@
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()
}
+76
View File
@@ -0,0 +1,76 @@
package db
import (
"time"
)
type ReplayEntry struct {
ID int64
Timestamp time.Time
Scheme string
Host string
Path string
Method string
OriginalRaw string
RequestRaw string
ResponseRaw string
StatusCode int
ErrorMsg string
}
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
res, err := d.conn.Exec(
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339),
e.Scheme, e.Host, e.Path, e.Method,
e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
e.StatusCode, e.ErrorMsg,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
_, err := d.conn.Exec(
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID,
)
return err
}
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
rows, err := d.conn.Query(
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg
FROM replay_entries ORDER BY id ASC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ReplayEntry
for rows.Next() {
var e ReplayEntry
var ts string
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 {
return nil, err
}
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
entries = append(entries, e)
}
return entries, rows.Err()
}
func (d *DB) DeleteReplayEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
return err
}
func (d *DB) DeleteAllReplayEntries() error {
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err
}
+45
View File
@@ -0,0 +1,45 @@
package db
func (d *DB) SaveScope(whitelist, blacklist []string) error {
tx, err := d.conn.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM scope`); err != nil {
tx.Rollback()
return err
}
for _, p := range whitelist {
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('whitelist', ?)`, p); err != nil {
tx.Rollback()
return err
}
}
for _, p := range blacklist {
if _, err := tx.Exec(`INSERT INTO scope (kind, pattern) VALUES ('blacklist', ?)`, p); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func (d *DB) LoadScope() (whitelist, blacklist []string, err error) {
rows, err := d.conn.Query(`SELECT kind, pattern FROM scope`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
for rows.Next() {
var kind, pattern string
if err := rows.Scan(&kind, &pattern); err != nil {
return nil, nil, err
}
if kind == "whitelist" {
whitelist = append(whitelist, pattern)
} else {
blacklist = append(blacklist, pattern)
}
}
return whitelist, blacklist, rows.Err()
}
+51
View File
@@ -0,0 +1,51 @@
package icons
import "github.com/anotherhadi/spilltea/internal/config"
type Icons struct {
Forward string
Drop string
Edit string
Intercept string
History string
Replay string
Diff string
Request string
Response string
Plugin string
Findings string
Scope string
Detail string
Docs string
New string
Temp string
Project string
}
var I *Icons
func Init(cfg *config.Config) {
if cfg.TUI.UseNerdfontIcons {
I = &Icons{
Forward: "󰁔 ",
Drop: "󰆴 ",
Edit: "󰏫 ",
Intercept: " ",
History: "󰋚 ",
Replay: "󰑙 ",
Diff: "󰕛 ",
Request: "󰜷 ",
Response: "󰜮 ",
Plugin: " ",
Findings: "󱎸 ",
Scope: "󰓾 ",
Detail: "󰱼 ",
Docs: " ",
New: "󰐕 ",
Temp: "󰙨 ",
Project: "󰉋 ",
}
} else {
I = &Icons{}
}
}
+222
View File
@@ -0,0 +1,222 @@
package intercept
import (
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/lqqyt2423/go-mitmproxy/proxy"
)
type Decision int
const (
Forward Decision = iota // forward without showing in intercept
Drop // drop the flow
Intercept // pass to the TUI for user decision
)
type PendingRequest struct {
Flow *proxy.Flow
decision chan Decision
ArrivedAt time.Time
}
type PendingResponse struct {
Flow *proxy.Flow
decision chan Decision
ArrivedAt time.Time
}
type Broker struct {
Incoming chan *PendingRequest
IncomingResponse chan *PendingResponse
captureResponse atomic.Bool
dbMu sync.RWMutex
database *db.DB
droppedFlows sync.Map // *proxy.Flow → struct{}
outOfScope sync.Map // *proxy.Flow → struct{}
scopeMu sync.RWMutex
whitelist []*regexp.Regexp
blacklist []*regexp.Regexp
onNewEntry func(db.Entry)
}
func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
b.onNewEntry = cb
}
// IsInScope reports whether the given target string (host+path) matches the
// current scope rules. Used by the plugin API.
func (b *Broker) IsInScope(target string) bool {
b.scopeMu.RLock()
wl := b.whitelist
bl := b.blacklist
b.scopeMu.RUnlock()
return scopeMatches(wl, bl, target)
}
func NewBroker() *Broker {
return &Broker{
Incoming: make(chan *PendingRequest, 64),
IncomingResponse: make(chan *PendingResponse, 64),
}
}
func (b *Broker) SetCaptureResponse(v bool) {
b.captureResponse.Store(v)
}
// SetScope compiles and stores whitelist/blacklist regex patterns.
// Invalid patterns are silently skipped.
func (b *Broker) SetScope(whitelist, blacklist []string) {
wl := compilePatterns(whitelist)
bl := compilePatterns(blacklist)
b.scopeMu.Lock()
b.whitelist = wl
b.blacklist = bl
b.scopeMu.Unlock()
}
func compilePatterns(patterns []string) []*regexp.Regexp {
out := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
if r, err := regexp.Compile(p); err == nil {
out = append(out, r)
}
}
return out
}
func (b *Broker) matchesScope(f *proxy.Flow) bool {
target := f.Request.URL.Host + f.Request.URL.Path
b.scopeMu.RLock()
wl := b.whitelist
bl := b.blacklist
b.scopeMu.RUnlock()
return scopeMatches(wl, bl, target)
}
func scopeMatches(wl, bl []*regexp.Regexp, target string) bool {
if len(wl) > 0 {
matched := false
for _, r := range wl {
if r.MatchString(target) {
matched = true
break
}
}
if !matched {
return false
}
}
for _, r := range bl {
if r.MatchString(target) {
return false
}
}
return true
}
func (b *Broker) SetDB(d *db.DB) {
b.dbMu.Lock()
b.database = d
b.dbMu.Unlock()
}
// Hold is called from the proxy addon: it blocks until a decision is made in the TUI.
func (b *Broker) Hold(f *proxy.Flow) Decision {
if !b.matchesScope(f) {
b.outOfScope.Store(f, struct{}{})
return Forward
}
p := &PendingRequest{
Flow: f,
decision: make(chan Decision, 1),
ArrivedAt: time.Now(),
}
b.Incoming <- p
d := <-p.decision
if d == Drop {
b.droppedFlows.Store(f, struct{}{})
}
return d
}
// HoldResponse is called from the proxy addon after receiving the response headers, but before reading the body.
func (b *Broker) HoldResponse(f *proxy.Flow) Decision {
if _, oos := b.outOfScope.Load(f); oos {
return Forward
}
if !b.captureResponse.Load() {
return Forward
}
p := &PendingResponse{
Flow: f,
decision: make(chan Decision, 1),
ArrivedAt: time.Now(),
}
b.IncomingResponse <- p
return <-p.decision
}
// SaveEntry persists the completed flow to the history DB.
// It must be called after HoldResponse and before modifying f.Response.
// Flows that were dropped at the request phase are silently skipped.
func (b *Broker) SaveEntry(f *proxy.Flow) {
b.dbMu.RLock()
d := b.database
b.dbMu.RUnlock()
if d == nil {
return
}
if _, oos := b.outOfScope.LoadAndDelete(f); oos {
return
}
if _, dropped := b.droppedFlows.LoadAndDelete(f); dropped {
return
}
status := 0
if f.Response != nil {
status = f.Response.StatusCode
}
r := f.Request
path := r.URL.Path
if path == "" {
path = "/"
}
if config.Global.History.SkipDuplicates {
body := string(r.Body)
if dup, _ := d.HasDuplicate(r.Method, r.URL.Host, path, body); dup {
return
}
}
entry, err := d.InsertEntry(db.Entry{
Timestamp: time.Now(),
Method: r.Method,
Host: r.URL.Host,
Path: path,
StatusCode: status,
RequestRaw: FormatRawRequest(f),
ResponseRaw: FormatRawResponse(f),
})
if err == nil {
if cb := b.onNewEntry; cb != nil {
go cb(entry)
}
}
}
func (b *Broker) Decide(p *PendingRequest, d Decision) {
p.decision <- d
}
func (b *Broker) DecideResponse(p *PendingResponse, d Decision) {
p.decision <- d
}
+18
View File
@@ -0,0 +1,18 @@
package intercept
import tea "charm.land/bubbletea/v2"
type RequestArrivedMsg struct{ Req *PendingRequest }
type ResponseArrivedMsg struct{ Resp *PendingResponse }
func WaitForRequest(b *Broker) tea.Cmd {
return func() tea.Msg {
return RequestArrivedMsg{Req: <-b.Incoming}
}
}
func WaitForResponse(b *Broker) tea.Cmd {
return func() tea.Msg {
return ResponseArrivedMsg{Resp: <-b.IncomingResponse}
}
}
+61
View File
@@ -0,0 +1,61 @@
package intercept
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/lqqyt2423/go-mitmproxy/proxy"
)
// FormatRawRequest serialises a flow's request to a raw HTTP string.
func FormatRawRequest(f *proxy.Flow) string {
r := f.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
// FormatRawResponse serialises a flow's response to a raw HTTP string.
func FormatRawResponse(f *proxy.Flow) string {
r := f.Response
if r == nil {
return "(no response)"
}
var sb strings.Builder
proto := f.Request.Proto
if proto == "" {
proto = "HTTP/1.1"
}
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
+20
View File
@@ -0,0 +1,20 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type DiffKeyMap struct {
Clear key.Binding
}
func newDiffKeyMap(cfg config.DiffKeys) DiffKeyMap {
return DiffKeyMap{
Clear: binding(cfg.Clear, "clear"),
}
}
func (d DiffKeyMap) Bindings() []key.Binding {
return []key.Binding{d.Clear}
}
+20
View File
@@ -0,0 +1,20 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type FindingsKeyMap struct {
Dismiss key.Binding
}
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
return FindingsKeyMap{
Dismiss: binding(cfg.Dismiss, "dismiss"),
}
}
func (f FindingsKeyMap) Bindings() []key.Binding {
return []key.Binding{f.Dismiss}
}
+54
View File
@@ -0,0 +1,54 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type GlobalKeyMap struct {
Quit key.Binding
OpenLogs key.Binding
ToggleSidebar key.Binding
Help key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
CycleFocus key.Binding
CopyRequest key.Binding
Escape key.Binding
SendToReplay key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
SendToDiff key.Binding
}
func newGlobalKeyMap(cfg config.GlobalKeys) GlobalKeyMap {
return GlobalKeyMap{
Quit: binding(cfg.Quit, "quit"),
OpenLogs: binding(cfg.OpenLogs, "open logs"),
ToggleSidebar: binding(cfg.ToggleSidebar, "toggle sidebar"),
Help: binding(cfg.Help, "help"),
Up: binding(cfg.Up, "up"),
Down: binding(cfg.Down, "down"),
Left: binding(cfg.Left, "scroll left"),
Right: binding(cfg.Right, "scroll right"),
CycleFocus: binding(cfg.CycleFocus, "cycle focus"),
CopyRequest: binding(cfg.CopyRequest, "copy as..."),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
SendToReplay: binding(cfg.SendToReplay, "send to replay"),
ScrollUp: binding(cfg.ScrollUp, "scroll up"),
ScrollDown: binding(cfg.ScrollDown, "scroll down"),
SendToDiff: binding(cfg.SendToDiff, "send to diff"),
}
}
func (g GlobalKeyMap) Bindings() []key.Binding {
return []key.Binding{
g.Up, g.Down, g.Left, g.Right, g.CycleFocus,
g.Quit, g.Escape, g.Help,
g.OpenLogs, g.ToggleSidebar, g.CopyRequest,
g.SendToReplay, g.SendToDiff,
g.ScrollUp, g.ScrollDown,
}
}
+26
View File
@@ -0,0 +1,26 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type HistoryKeyMap struct {
DeleteEntry key.Binding
DeleteAll key.Binding
Filter key.Binding
SqlQuery key.Binding
}
func newHistoryKeyMap(cfg config.HistoryKeys) HistoryKeyMap {
return HistoryKeyMap{
DeleteEntry: binding(cfg.DeleteEntry, "delete entry"),
DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"),
SqlQuery: binding(cfg.SqlQuery, "sql query"),
}
}
func (h HistoryKeyMap) Bindings() []key.Binding {
return []key.Binding{h.DeleteEntry, h.DeleteAll}
}
+24
View File
@@ -0,0 +1,24 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type HomeKeyMap struct {
Open key.Binding
Delete key.Binding
Filter key.Binding
}
func newHomeKeyMap(cfg config.HomeKeys) HomeKeyMap {
return HomeKeyMap{
Open: binding(cfg.Open, "open"),
Delete: binding(cfg.Delete, "delete project"),
Filter: binding(cfg.Filter, "filter"),
}
}
func (h HomeKeyMap) Bindings() []key.Binding {
return []key.Binding{h.Open, h.Delete, h.Filter}
}
+41
View File
@@ -0,0 +1,41 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type InterceptKeyMap struct {
Forward key.Binding
ForwardAll key.Binding
Drop key.Binding
DropAll key.Binding
AutoForward key.Binding
CaptureResponse key.Binding
UndoEdits key.Binding
Edit key.Binding
EditExternal key.Binding
}
func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap {
return InterceptKeyMap{
Forward: binding(cfg.Forward, "forward"),
ForwardAll: binding(cfg.ForwardAll, "forward all"),
Drop: binding(cfg.Drop, "drop"),
DropAll: binding(cfg.DropAll, "drop all"),
AutoForward: binding(cfg.AutoForward, "auto forward"),
CaptureResponse: binding(cfg.CaptureResponse, "capture response"),
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Edit: binding(cfg.Edit, "edit"),
EditExternal: binding(cfg.EditExternal, "edit in $EDITOR"),
}
}
func (ic InterceptKeyMap) Bindings() []key.Binding {
return []key.Binding{
ic.Forward, ic.ForwardAll,
ic.Drop, ic.DropAll,
ic.Edit, ic.EditExternal, ic.UndoEdits,
ic.AutoForward, ic.CaptureResponse,
}
}
+72
View File
@@ -0,0 +1,72 @@
package keys
import (
"strings"
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type KeyMap struct {
Global GlobalKeyMap
Intercept InterceptKeyMap
Home HomeKeyMap
History HistoryKeyMap
Replay ReplayKeyMap
Diff DiffKeyMap
Findings FindingsKeyMap
Plugins PluginsKeyMap
}
var Keys *KeyMap
func Init(cfg *config.Config) {
kb := cfg.Keybindings
Keys = &KeyMap{
Global: newGlobalKeyMap(kb.Global),
Intercept: newInterceptKeyMap(kb.Intercept),
Home: newHomeKeyMap(kb.Home),
History: newHistoryKeyMap(kb.History),
Replay: newReplayKeyMap(kb.Replay),
Diff: newDiffKeyMap(kb.Diff),
Findings: newFindingsKeyMap(kb.Findings),
Plugins: newPluginsKeyMap(kb.Plugins),
}
}
func parseKeys(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if k := strings.TrimSpace(p); k != "" {
out = append(out, k)
}
}
return out
}
func binding(s, help string) key.Binding {
keys := parseKeys(s)
display := strings.Join(keys, "/")
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help))
}
// ChunkByWidth splits bindings into columns sized to fit the terminal width.
func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding {
cols := termWidth / 26
if cols < 2 {
cols = 2
} else if cols > 7 {
cols = 7
}
perCol := (len(bindings) + cols - 1) / cols
var out [][]key.Binding
for i := 0; i < len(bindings); i += perCol {
end := i + perCol
if end > len(bindings) {
end = len(bindings)
}
out = append(out, bindings[i:end])
}
return out
}
+24
View File
@@ -0,0 +1,24 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type PluginsKeyMap struct {
Toggle key.Binding
EditConfig key.Binding
Filter key.Binding
}
func newPluginsKeyMap(cfg config.PluginsKeys) PluginsKeyMap {
return PluginsKeyMap{
Toggle: binding(cfg.Toggle, "toggle"),
EditConfig: binding(cfg.EditConfig, "edit config"),
Filter: binding(cfg.Filter, "filter"),
}
}
func (p PluginsKeyMap) Bindings() []key.Binding {
return []key.Binding{p.Toggle, p.EditConfig, p.Filter}
}
+30
View File
@@ -0,0 +1,30 @@
package keys
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/spilltea/internal/config"
)
type ReplayKeyMap struct {
Send key.Binding
Edit key.Binding
EditExt key.Binding
UndoEdits key.Binding
Delete key.Binding
DeleteAll key.Binding
}
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
return ReplayKeyMap{
Send: binding(cfg.Send, "send"),
Edit: binding(cfg.Edit, "edit"),
EditExt: binding(cfg.EditExt, "edit in $EDITOR"),
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Delete: binding(cfg.Delete, "delete"),
DeleteAll: binding(cfg.DeleteAll, "delete all"),
}
}
func (r ReplayKeyMap) Bindings() []key.Binding {
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll}
}
+15
View File
@@ -0,0 +1,15 @@
package plugins
import tea "charm.land/bubbletea/v2"
func WaitForNotif(mgr *Manager) tea.Cmd {
return func() tea.Msg {
return <-mgr.Notifs
}
}
func WaitForQuit(mgr *Manager) tea.Cmd {
return func() tea.Msg {
return PluginQuitMsg{Reason: <-mgr.Quit}
}
}
+206
View File
@@ -0,0 +1,206 @@
package plugins
import (
"log"
"net/url"
"strings"
"time"
"github.com/anotherhadi/spilltea/internal/db"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua"
)
func newLuaState(mgr *Manager, p *Plugin) *lua.LState {
L := lua.NewState()
registerUtilities(L, mgr, p)
return L
}
func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
msg := L.CheckString(1)
log.Printf("[plugin:%s] %s", p.Name, msg)
return 0
}))
L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1)
body := L.CheckString(2)
select {
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}:
default:
}
return 0
}))
L.SetGlobal("create_finding", L.NewFunction(func(L *lua.LState) int {
t := L.CheckTable(1)
title := luaTableString(t, "title")
desc := luaTableString(t, "description")
key := luaTableString(t, "key")
severity := luaTableString(t, "severity")
if severity == "" {
severity = "info"
}
if key == "" {
key = title
}
if mgr.db == nil {
return 0
}
inserted, err := mgr.db.UpsertFinding(db.Finding{
PluginName: p.Name,
DedupKey: key,
Title: title,
Description: desc,
Severity: severity,
CreatedAt: time.Now(),
})
if err != nil {
log.Printf("[plugin:%s] create_finding error: %v", p.Name, err)
return 0
}
_ = inserted
return 0
}))
L.SetGlobal("is_in_scope", L.NewFunction(func(L *lua.LState) int {
raw := L.CheckString(1)
if mgr.broker == nil {
L.Push(lua.LTrue)
return 1
}
u, err := url.Parse(raw)
if err != nil {
L.Push(lua.LFalse)
return 1
}
path := u.Path
if path == "" {
path = "/"
}
L.Push(lua.LBool(mgr.broker.IsInScope(u.Host + path)))
return 1
}))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
reason := L.OptString(1, "plugin requested quit")
select {
case mgr.Quit <- reason:
default:
}
return 0
}))
}
func luaTableString(t *lua.LTable, key string) string {
v := t.RawGetString(key)
if s, ok := v.(lua.LString); ok {
return string(s)
}
return ""
}
func pushRequest(L *lua.LState, f *goproxy.Flow) *lua.LTable {
t := L.NewTable()
r := f.Request
L.SetField(t, "method", lua.LString(r.Method))
L.SetField(t, "url", lua.LString(r.URL.String()))
L.SetField(t, "host", lua.LString(r.URL.Host))
L.SetField(t, "path", lua.LString(r.URL.Path))
headers := L.NewTable()
for k, vals := range r.Header {
L.SetField(headers, k, lua.LString(strings.Join(vals, ", ")))
}
L.SetField(t, "headers", headers)
L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(string(r.Body)))
return 1
}))
L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int {
name := L.CheckString(2)
value := L.CheckString(3)
r.Header.Set(name, value)
return 0
}))
L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int {
body := L.CheckString(2)
r.Body = []byte(body)
return 0
}))
return t
}
func pushResponse(L *lua.LState, f *goproxy.Flow) *lua.LTable {
t := L.NewTable()
if f.Response == nil {
return t
}
resp := f.Response
L.SetField(t, "status_code", lua.LNumber(resp.StatusCode))
headers := L.NewTable()
for k, vals := range resp.Header {
L.SetField(headers, k, lua.LString(strings.Join(vals, ", ")))
}
L.SetField(t, "headers", headers)
L.SetField(t, "get_body", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(string(resp.Body)))
return 1
}))
L.SetField(t, "set_header", L.NewFunction(func(L *lua.LState) int {
name := L.CheckString(2)
value := L.CheckString(3)
resp.Header.Set(name, value)
return 0
}))
L.SetField(t, "set_body", L.NewFunction(func(L *lua.LState) int {
body := L.CheckString(2)
resp.Body = []byte(body)
return 0
}))
return t
}
func pushEntry(L *lua.LState, e db.Entry) *lua.LTable {
t := L.NewTable()
L.SetField(t, "id", lua.LNumber(e.ID))
L.SetField(t, "method", lua.LString(e.Method))
L.SetField(t, "host", lua.LString(e.Host))
L.SetField(t, "path", lua.LString(e.Path))
L.SetField(t, "status_code", lua.LNumber(e.StatusCode))
L.SetField(t, "timestamp", lua.LString(e.Timestamp.Format("2006-01-02 15:04:05")))
L.SetField(t, "request_raw", lua.LString(e.RequestRaw))
L.SetField(t, "response_raw", lua.LString(e.ResponseRaw))
return t
}
func callHook(p *Plugin, hookName string, args ...lua.LValue) (string, error) {
fn := p.L.GetGlobal(hookName)
if fn == lua.LNil {
return "", nil
}
if err := p.L.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, args...); err != nil {
return "", err
}
ret := p.L.Get(-1)
p.L.Pop(1)
if s, ok := ret.(lua.LString); ok {
return string(s), nil
}
return "", nil
}
+346
View File
@@ -0,0 +1,346 @@
package plugins
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/intercept"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
lua "github.com/yuin/gopher-lua"
)
type Manager struct {
mu sync.RWMutex
plugins []*Plugin
db *db.DB
broker *intercept.Broker
Notifs chan PluginNotifMsg
Quit chan string
}
func NewManager(broker *intercept.Broker) *Manager {
mgr := &Manager{
broker: broker,
Notifs: make(chan PluginNotifMsg, 64),
Quit: make(chan string, 4),
}
if broker != nil {
broker.SetOnNewEntry(mgr.RunOnHistoryEntry)
}
return mgr
}
func (m *Manager) SetDB(d *db.DB) {
m.db = d
}
func (m *Manager) LoadFromDir(dir string) error {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
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
}
path := filepath.Join(dir, e.Name())
p, err := m.loadPlugin(path)
if err != nil {
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
}
m.mu.Lock()
m.plugins = append(m.plugins, p)
m.mu.Unlock()
}
return nil
}
func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p := &Plugin{
FilePath: path,
Enabled: true,
hooks: make(map[string]HookConfig),
}
p.L = newLuaState(m, p)
if err := p.L.DoFile(path); err != nil {
p.L.Close()
return nil, err
}
pluginTable, ok := p.L.GetGlobal("Plugin").(*lua.LTable)
if !ok {
p.L.Close()
return nil, fmt.Errorf("missing Plugin table")
}
if s, ok := pluginTable.RawGetString("name").(lua.LString); ok {
p.Name = string(s)
}
if p.Name == "" {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
}
// Defaults when not overridden by the Plugin table.
hookDefaults := map[string]bool{
"on_start": true, // always sync
"on_request": false, // async
"on_response": false, // async
"on_quit": true, // always sync
"on_history_entry": false, // always async
}
for hookName, defaultSync := range hookDefaults {
// Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed).
if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue
}
}
// Auto-detect: register the hook if the function exists as a global.
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: defaultSync}
}
}
return p, nil
}
func (m *Manager) GetPlugins() []*Plugin {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins)
return out
}
func (m *Manager) TogglePlugin(name string) {
m.mu.RLock()
var found *Plugin
for _, p := range m.plugins {
if p.Name == name {
found = p
break
}
}
m.mu.RUnlock()
if found == nil {
return
}
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)
}
}
func (m *Manager) SaveConfig(name, configText string) {
m.mu.RLock()
var found *Plugin
for _, p := range m.plugins {
if p.Name == name {
found = p
break
}
}
m.mu.RUnlock()
if found == nil {
return
}
found.mu.Lock()
found.ConfigText = configText
enabled := found.Enabled
hc, hasOnStart := found.hooks["on_start"]
found.mu.Unlock()
if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText)
}
if !hasOnStart {
return
}
// Re-run on_start so the plugin can re-parse the new config.
if hc.Sync {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
}()
}
}
func (m *Manager) RunOnStart() {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_start"]; !ok {
continue
}
p.mu.Lock()
if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
}
}
func (m *Manager) RunOnQuit() {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_quit"]; !ok {
continue
}
p.mu.Lock()
if _, err := callHook(p, "on_quit"); err != nil {
log.Printf("plugin %s on_quit: %v", p.Name, err)
}
p.mu.Unlock()
}
}
func (m *Manager) RunSyncOnRequest(f *goproxy.Flow) intercept.Decision {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_request"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_request", pushRequest(p.L, f))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err)
continue
}
switch result {
case "drop":
return intercept.Drop
case "forward":
return intercept.Forward
}
}
return intercept.Intercept
}
func (m *Manager) RunAsyncOnRequest(f *goproxy.Flow) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_request"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_request", pushRequest(p.L, f)); err != nil {
log.Printf("plugin %s on_request: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
}
func (m *Manager) RunSyncOnResponse(f *goproxy.Flow) intercept.Decision {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_response"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
continue
}
switch result {
case "drop":
return intercept.Drop
case "forward":
return intercept.Forward
}
}
return intercept.Intercept
}
func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_response"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_response", pushRequest(p.L, f), pushResponse(p.L, f)); err != nil {
log.Printf("plugin %s on_response: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
}
func (m *Manager) RunOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_history_entry"]; !ok {
continue
}
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_history_entry", pushEntry(p.L, e)); err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
}
+66
View File
@@ -0,0 +1,66 @@
package plugins
import (
"sync"
lua "github.com/yuin/gopher-lua"
)
type HookConfig struct {
Sync bool
}
type Plugin struct {
Name string
FilePath string
Enabled bool
ConfigText string
L *lua.LState
mu sync.Mutex
hooks map[string]HookConfig
}
func (p *Plugin) HookNames() []string {
out := make([]string, 0, len(p.hooks))
for name := range p.hooks {
out = append(out, name)
}
return out
}
func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
hc, ok := p.hooks[name]
return hc, ok
}
type Info struct {
Name string
FilePath string
Enabled bool
ConfigText string
Hooks map[string]HookConfig
}
func (p *Plugin) Info() Info {
hooks := make(map[string]HookConfig, len(p.hooks))
for k, v := range p.hooks {
hooks[k] = v
}
return Info{
Name: p.Name,
FilePath: p.FilePath,
Enabled: p.Enabled,
ConfigText: p.ConfigText,
Hooks: hooks,
}
}
type PluginNotifMsg struct {
Title string
Body string
}
type PluginQuitMsg struct {
Reason string
}
+128
View File
@@ -0,0 +1,128 @@
package proxy
import (
"fmt"
"io"
"net/http"
"os"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins"
goproxy "github.com/lqqyt2423/go-mitmproxy/proxy"
)
type ErrMsg struct{ Err error }
func StartCmd(broker *intercept.Broker, mgr *plugins.Manager) tea.Cmd {
return func() tea.Msg {
if err := Start(broker, mgr); err != nil {
return ErrMsg{Err: err}
}
return ErrMsg{}
}
}
type interceptAddon struct {
goproxy.BaseAddon
broker *intercept.Broker
plugins *plugins.Manager
}
// ClientConnected disables upstream cert fetching so the upstream TCP/TLS
// connection is established only after Hold() returns, not during CONNECT.
// Without this, the upstream connection sits idle while the TUI holds the
// request, and the server closes it (keep-alive timeout) → unexpected EOF.
func (a *interceptAddon) ClientConnected(clientConn *goproxy.ClientConn) {
clientConn.UpstreamCert = false
}
func (a *interceptAddon) Request(f *goproxy.Flow) {
if a.plugins != nil {
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)
return
}
}
if a.broker.Hold(f) == intercept.Drop {
f.Response = dropResponse()
}
if a.plugins != nil {
go a.plugins.RunAsyncOnRequest(f)
}
}
func (a *interceptAddon) Response(f *goproxy.Flow) {
if f.Response != nil {
if len(f.Response.Body) == 0 && f.Response.BodyReader != nil {
body, _ := io.ReadAll(f.Response.BodyReader)
f.Response.Body = body
f.Response.BodyReader = nil
}
f.Response.ReplaceToDecodedBody()
}
if a.plugins != nil {
switch a.plugins.RunSyncOnResponse(f) {
case intercept.Drop:
a.broker.SaveEntry(f)
f.Response = dropResponse()
go a.plugins.RunAsyncOnResponse(f)
return
case intercept.Forward:
a.broker.SaveEntry(f)
go a.plugins.RunAsyncOnResponse(f)
return
}
}
decision := a.broker.HoldResponse(f)
a.broker.SaveEntry(f)
if decision == intercept.Drop {
f.Response = dropResponse()
}
if a.plugins != nil {
go a.plugins.RunAsyncOnResponse(f)
}
}
func Start(broker *intercept.Broker, mgr *plugins.Manager) error {
cfg := config.Global.App
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
caPath := config.ExpandPath(cfg.CertDir)
if err := os.MkdirAll(caPath, 0o700); err != nil {
return fmt.Errorf("ca dir: %w", err)
}
opts := &goproxy.Options{
Addr: addr,
StreamLargeBodies: 1024 * 1024 * 5,
CaRootPath: caPath,
}
p, err := goproxy.NewProxy(opts)
if err != nil {
return err
}
p.AddAddon(&interceptAddon{broker: broker, plugins: mgr})
return p.Start()
}
func dropResponse() *goproxy.Response {
return &goproxy.Response{
StatusCode: 502,
Header: http.Header{"Content-Type": []string{"text/plain"}},
Body: []byte("Dropped by spilltea"),
}
}
+43
View File
@@ -0,0 +1,43 @@
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
}
topLine := "╭" + label + strings.Repeat("─", fillW) + "╮"
topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine)
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+84
View File
@@ -0,0 +1,84 @@
package style
import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
)
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
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ta.SetStyles(ts)
return ta
}
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
func SeverityStyle(sev string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true)
switch sev {
case "critical":
return base.Foreground(S.Error)
case "high":
return base.Foreground(S.Warning)
case "medium":
return base.Foreground(S.Primary)
case "low":
return base.Foreground(S.Success)
default:
return base.Foreground(S.Subtle)
}
}
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
func StatusStyle(code, width int) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(width)
switch {
case code >= 500:
return base.Foreground(S.Error)
case code >= 400:
return base.Foreground(S.Warning)
case code >= 300:
return base.Foreground(S.Primary)
default:
return base.Foreground(S.Success)
}
}
// SplitH splits totalHeight into top and bottom sections, accounting for the
// status bar height.
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
+236
View File
@@ -0,0 +1,236 @@
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> ",
},
}
}
+333
View File
@@ -0,0 +1,333 @@
package style
import (
"bytes"
"encoding/json"
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"golang.org/x/net/html"
)
func Paint(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s)
}
// HighlightHTTP highlights a full raw HTTP message (headers + body).
func HighlightHTTP(raw string) string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
idx := strings.Index(raw, "\n\n")
if idx == -1 {
return highlightHeaders(raw)
}
headers := raw[:idx+2]
body := raw[idx+2:]
result := highlightHeaders(headers)
if body == "" {
return result
}
pretty := config.Global != nil && config.Global.TUI.PrettyPrintBody
switch detectBodyType(headers) {
case "json":
if pretty {
body = prettyJSON(body)
}
result += highlightJSON(body)
case "html":
if pretty {
body = prettyHTML(body)
}
result += highlightHTML(body)
default:
result += body
}
return result
}
func detectBodyType(headers string) string {
for _, line := range strings.Split(headers, "\n") {
lower := strings.ToLower(line)
if !strings.HasPrefix(lower, "content-type:") {
continue
}
ct := strings.ToLower(strings.TrimSpace(line[len("content-type:"):]))
switch {
case strings.Contains(ct, "json"):
return "json"
case strings.Contains(ct, "html"):
return "html"
}
break
}
return ""
}
func highlightHeaders(raw string) string {
var out strings.Builder
lines := strings.Split(raw, "\n")
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r")
if i == 0 {
out.WriteString(highlightStatusLine(trimmed))
} 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:]))
} else {
out.WriteString(line)
}
if i < len(lines)-1 {
out.WriteByte('\n')
}
}
return out.String()
}
func highlightStatusLine(line string) string {
parts := strings.SplitN(line, " ", 3)
if len(parts) < 2 {
return line
}
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])
if len(parts) == 3 {
result += " " + Paint(S.Subtle, parts[2])
}
return result
}
result := Paint(S.Subtle, parts[0]) + " "
result += Paint(S.Warning, parts[1])
if len(parts) == 3 {
result += " " + Paint(S.MutedFg, parts[2])
}
return result
}
func highlightJSON(s string) string {
var out strings.Builder
i, n := 0, len(s)
for i < n {
ch := s[i]
switch {
case ch == '"':
j := i + 1
for j < n {
if s[j] == '\\' {
j += 2
continue
}
if s[j] == '"' {
j++
break
}
j++
}
str := s[i:j]
k := j
for k < n && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < n && s[k] == ':' {
out.WriteString(Paint(S.Primary, str))
} else {
out.WriteString(Paint(S.Success, str))
}
i = j
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
j := i
if s[j] == '-' {
j++
}
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]))
i = j
case i+4 <= n && s[i:i+4] == "true":
out.WriteString(Paint(S.Error, "true"))
i += 4
case i+5 <= n && s[i:i+5] == "false":
out.WriteString(Paint(S.Error, "false"))
i += 5
case i+4 <= n && s[i:i+4] == "null":
out.WriteString(Paint(S.Error, "null"))
i += 4
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
out.WriteString(Paint(S.Subtle, string(ch)))
i++
default:
out.WriteByte(ch)
i++
}
}
return out.String()
}
func prettyJSON(s string) string {
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(strings.TrimSpace(s)), "", " "); err != nil {
return s
}
return buf.String()
}
var voidHTMLElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
func prettyHTML(s string) string {
doc, err := html.Parse(strings.NewReader(s))
if err != nil {
return s
}
var buf strings.Builder
walkHTMLNode(&buf, doc, 0)
return strings.TrimRight(buf.String(), "\n")
}
func walkHTMLNode(w *strings.Builder, n *html.Node, depth int) {
indent := strings.Repeat(" ", depth)
switch n.Type {
case html.DocumentNode:
for c := n.FirstChild; c != nil; c = c.NextSibling {
walkHTMLNode(w, c, depth)
}
case html.DoctypeNode:
w.WriteString("<!DOCTYPE " + n.Data + ">\n")
case html.CommentNode:
w.WriteString(indent + "<!--" + n.Data + "-->\n")
case html.TextNode:
text := strings.TrimSpace(n.Data)
if text != "" {
w.WriteString(indent + text + "\n")
}
case html.ElementNode:
tag := buildHTMLOpenTag(n)
if voidHTMLElements[n.Data] {
w.WriteString(indent + tag + "\n")
return
}
w.WriteString(indent + tag + "\n")
if n.Data == "script" || n.Data == "style" {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
text := strings.TrimSpace(c.Data)
if text != "" {
for _, line := range strings.Split(text, "\n") {
w.WriteString(indent + " " + line + "\n")
}
}
}
}
} else {
for c := n.FirstChild; c != nil; c = c.NextSibling {
walkHTMLNode(w, c, depth+1)
}
}
w.WriteString(indent + "</" + n.Data + ">\n")
}
}
func buildHTMLOpenTag(n *html.Node) string {
var sb strings.Builder
sb.WriteString("<" + n.Data)
for _, attr := range n.Attr {
sb.WriteString(" ")
if attr.Namespace != "" {
sb.WriteString(attr.Namespace + ":")
}
sb.WriteString(attr.Key + `="` + escapeHTMLAttr(attr.Val) + `"`)
}
sb.WriteString(">")
return sb.String()
}
func escapeHTMLAttr(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func highlightHTML(s string) string {
var out strings.Builder
i, n := 0, len(s)
for i < n {
if i+4 <= n && s[i:i+4] == "<!--" {
end := strings.Index(s[i:], "-->")
if end == -1 {
out.WriteString(Paint(S.Subtle, s[i:]))
break
}
end = i + end + 3
out.WriteString(Paint(S.Subtle, s[i:end]))
i = end
continue
}
if s[i] != '<' {
out.WriteByte(s[i])
i++
continue
}
out.WriteString(Paint(S.Subtle, "<"))
i++
if i < n && (s[i] == '/' || s[i] == '!') {
out.WriteString(Paint(S.Subtle, string(s[i])))
i++
}
j := i
for j < n && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' && s[j] != '\r' {
j++
}
if j > i {
out.WriteString(Paint(S.Primary, s[i:j]))
i = j
}
for i < n && s[i] != '>' {
ch := s[i]
switch {
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
out.WriteByte(ch)
i++
case ch == '/':
out.WriteString(Paint(S.Subtle, "/"))
i++
case ch == '=':
out.WriteString(Paint(S.Subtle, "="))
i++
case ch == '"' || ch == '\'':
q := ch
j = i + 1
for j < n && s[j] != q {
j++
}
if j < n {
j++
}
out.WriteString(Paint(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]))
i = j
}
}
if i < n && s[i] == '>' {
out.WriteString(Paint(S.Subtle, ">"))
i++
}
}
return out.String()
}
+100
View File
@@ -0,0 +1,100 @@
package style
import (
"image/color"
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
)
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
PagerDotActive string
PagerDotInactive string
}
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
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),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
}
}
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)
case "POST":
return base.Foreground(s.Warning)
case "PUT", "PATCH":
return base.Foreground(s.Primary)
case "DELETE":
return base.Foreground(s.Error)
default:
return base.Foreground(s.Text)
}
}
+137
View File
@@ -0,0 +1,137 @@
package app
import (
"log"
"os"
"path/filepath"
"strconv"
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
"github.com/sirupsen/logrus"
)
const tickInterval = 2 * time.Second
type tickMsg struct{}
func tickCmd() tea.Cmd {
return func() tea.Msg {
time.Sleep(tickInterval)
return tickMsg{}
}
}
var sidebarEntries = pageRegistry
var pageShortcuts = func() map[string]page {
m := make(map[string]page, len(sidebarEntries))
for i, e := range sidebarEntries {
m[strconv.Itoa(i+1)] = e.id
}
return m
}()
type Model struct {
broker *intercept.Broker
page page
projectName string
projectPath string
database *db.DB
logFile *os.File
pluginManager *plugins.Manager
width int
height int
sidebarState sidebarState
intercept interceptUI.Model
history historyUI.Model
replay replayUI.Model
diff diffUI.Model
docs docsUI.Model
scope scopeUI.Model
pluginsPage pluginsUI.Model
findingsPage findingsUI.Model
copyAs copyasUI.Model
notifications notificationsUI.Model
}
func New(broker *intercept.Broker, name, path string) Model {
cfg := config.Global
mgr := plugins.NewManager(broker)
m := Model{
broker: broker,
page: pageIntercept,
projectName: name,
projectPath: path,
pluginManager: mgr,
intercept: interceptUI.New(broker),
history: historyUI.New(),
replay: replayUI.New(),
diff: diffUI.New(),
docs: docsUI.New(),
scope: scopeUI.New(name, path),
pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(),
copyAs: copyasUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
}
if d, err := db.Open(path); err == nil {
m.database = d
broker.SetDB(d)
m.history.SetDB(d)
m.replay.SetDB(d)
m.findingsPage.SetDB(d)
mgr.SetDB(d)
if wl, bl, err := d.LoadScope(); err == nil {
broker.SetScope(wl, bl)
m.scope.SetScope(wl, bl)
}
}
pluginsDir := config.ExpandPath(cfg.App.PluginsDir)
if err := mgr.LoadFromDir(pluginsDir); err != nil {
log.Printf("plugins: %v", err)
}
m.pluginsPage.Refresh()
if lf, err := os.OpenFile(filepath.Join(filepath.Dir(path), "logs.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
m.logFile = lf
log.SetOutput(lf)
logrus.SetOutput(lf)
}
return m
}
func (m Model) Init() tea.Cmd {
mgr := m.pluginManager
return tea.Batch(
intercept.WaitForRequest(m.broker),
intercept.WaitForResponse(m.broker),
tickCmd(),
proxyPkg.StartCmd(m.broker, mgr),
plugins.WaitForNotif(mgr),
plugins.WaitForQuit(mgr),
findingsUI.RefreshCmd(m.database),
func() tea.Msg { mgr.RunOnStart(); return nil },
)
}
+146
View File
@@ -0,0 +1,146 @@
package app
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/icons"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
docsUI "github.com/anotherhadi/spilltea/internal/ui/docs"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
pluginsUI "github.com/anotherhadi/spilltea/internal/ui/plugins"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
type page string
const (
pageIntercept page = "Intercept"
pageHistory page = "History"
pageReplay page = "Replay"
pageDiff page = "Diff"
pageScopes page = "Scopes"
pagePlugins page = "Plugins"
pageFindings page = "Findings"
pageDocs page = "Docs"
)
// pageEntry describes a page and all its integration hooks.
type pageEntry struct {
id page
icon func() string
// render returns the page's view content. nil = show "empty".
render func(m *Model) string
// update is called when this page is active. nil = no-op.
update func(m *Model, msg tea.Msg) tea.Cmd
// isEditing reports whether the page is in text-editing mode.
isEditing func(m *Model) bool
// resize propagates a new (w, h) to the page model.
resize func(m *Model, w, h int)
}
var pageRegistry = []pageEntry{
{
id: pageIntercept,
icon: func() string { return icons.I.Intercept },
render: func(m *Model) string { return m.intercept.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.intercept.IsEditing() },
resize: func(m *Model, w, h int) { m.intercept.SetSize(w, h) },
},
{
id: pageHistory,
icon: func() string { return icons.I.History },
render: func(m *Model) string { return m.history.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.history.IsEditing() },
resize: func(m *Model, w, h int) { m.history.SetSize(w, h) },
},
{
id: pageReplay,
icon: func() string { return icons.I.Replay },
render: func(m *Model) string { return m.replay.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.replay.IsEditing() },
resize: func(m *Model, w, h int) { m.replay.SetSize(w, h) },
},
{
id: pageDiff,
icon: func() string { return icons.I.Diff },
render: func(m *Model) string { return m.diff.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.diff.SetSize(w, h) },
},
{
id: pageScopes,
icon: func() string { return icons.I.Scope },
render: func(m *Model) string { return m.scope.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.scope.Update(msg)
m.scope = updated.(scopeUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.scope.IsEditing() },
resize: func(m *Model, w, h int) { m.scope.SetSize(w, h) },
},
{
id: pagePlugins,
icon: func() string { return icons.I.Plugin },
render: func(m *Model) string { return m.pluginsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.pluginsPage.Update(msg)
m.pluginsPage = updated.(pluginsUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.pluginsPage.IsEditing() },
resize: func(m *Model, w, h int) { m.pluginsPage.SetSize(w, h) },
},
{
id: pageFindings,
icon: func() string { return icons.I.Findings },
render: func(m *Model) string { return m.findingsPage.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.findingsPage.SetSize(w, h) },
},
{
id: pageDocs,
icon: func() string { return icons.I.Docs },
render: func(m *Model) string { return m.docs.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.docs.Update(msg)
m.docs = updated.(docsUI.Model)
return cmd
},
resize: func(m *Model, w, h int) { m.docs.SetSize(w, h) },
},
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"strconv"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
type sidebarState string
const (
sidebarHidden sidebarState = "hidden"
sidebarCollapsed sidebarState = "collapsed"
sidebarExpanded sidebarState = "expanded"
)
func (m *Model) cycleSidebarState() {
switch m.sidebarState {
case sidebarHidden:
m.sidebarState = sidebarCollapsed
case sidebarCollapsed:
m.sidebarState = sidebarExpanded
default:
m.sidebarState = sidebarHidden
}
}
func (m Model) getSidebarWidth() int {
switch m.sidebarState {
case sidebarHidden:
return 0
case sidebarCollapsed:
return 8
default:
return 18
}
}
func (m *Model) renderSidebar() string {
if m.sidebarState == sidebarHidden {
return ""
}
s := style.S
// content width inside bordered panel
inner := m.getSidebarWidth() - 2
titleText := "SPILLTEA"
if m.sidebarState == sidebarCollapsed {
titleText = "SPLT"
}
title := lipgloss.NewStyle().Width(inner).Bold(true).Foreground(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)
lineStyle := lipgloss.NewStyle().Width(inner).Padding(0, 1)
var items strings.Builder
for i, entry := range sidebarEntries {
selected := entry.id == m.page
badgeStyle, textStyle := badgeNormal, textNormal
if selected {
badgeStyle, textStyle = badgeSelected, textSelected
}
icon := ""
if entry.icon != nil {
icon = entry.icon()
}
label := " " + icon
if m.sidebarState != sidebarCollapsed {
label += string(entry.id)
}
line := lineStyle.Render(badgeStyle.Render(strconv.Itoa(i+1)) + textStyle.Render(label))
items.WriteString(line + "\n")
}
body := lipgloss.JoinVertical(lipgloss.Left,
title,
lipgloss.NewStyle().Foreground(s.Subtle).Render(divider),
items.String(),
)
return s.Panel.Width(m.getSidebarWidth()).Height(m.height).Render(body)
}
+238
View File
@@ -0,0 +1,238 @@
package app
import (
"log"
"os"
"os/exec"
"path/filepath"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
proxyPkg "github.com/anotherhadi/spilltea/internal/proxy"
copyasUI "github.com/anotherhadi/spilltea/internal/ui/components/copyas"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
findingsUI "github.com/anotherhadi/spilltea/internal/ui/findings"
historyUI "github.com/anotherhadi/spilltea/internal/ui/history"
interceptUI "github.com/anotherhadi/spilltea/internal/ui/intercept"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
scopeUI "github.com/anotherhadi/spilltea/internal/ui/scope"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Broker messages must always re-register their watchers
switch msg := msg.(type) {
case notificationsUI.NotificationMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case notificationsUI.DismissMsg:
var cmd tea.Cmd
m.notifications, cmd = m.notifications.Update(msg)
return m, cmd
case intercept.RequestArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForRequest(m.broker))
case intercept.ResponseArrivedMsg:
updated, cmd := m.intercept.Update(msg)
m.intercept = updated.(interceptUI.Model)
return m, tea.Batch(cmd, intercept.WaitForResponse(m.broker))
case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager)
notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: msg.Title,
Body: msg.Body,
Kind: notificationsUI.KindInfo,
}
}
return m, tea.Batch(cmd, notifCmd)
case plugins.PluginQuitMsg:
log.Printf("plugin quit: %s", msg.Reason)
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
if m.copyAs.IsOpen() {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.width = ws.Width
m.height = ws.Height
m.copyAs.SetSize(ws.Width, ws.Height)
m.resizeChildren()
return m, nil
}
var cmd tea.Cmd
m.copyAs, cmd = m.copyAs.Update(msg)
return m, cmd
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeChildren()
case scopeUI.ScopeChangedMsg:
m.broker.SetScope(msg.Whitelist, msg.Blacklist)
if m.database != nil {
if err := m.database.SaveScope(msg.Whitelist, msg.Blacklist); err != nil {
log.Printf("failed to persist scope: %v", err)
}
}
return m, nil
case proxyPkg.ErrMsg:
if msg.Err != nil {
log.Printf("proxy error: %v", msg.Err)
}
return m, nil
case tickMsg:
var cmds []tea.Cmd
cmds = append(cmds, tickCmd())
if m.page == pageHistory {
cmds = append(cmds, m.history.RefreshCmd())
}
cmds = append(cmds, findingsUI.RefreshCmd(m.database))
return m, tea.Batch(cmds...)
case findingsUI.FindingsLoadedMsg:
updated, cmd := m.findingsPage.Update(msg)
m.findingsPage = updated.(findingsUI.Model)
return m, cmd
case replayUI.SendToReplayMsg:
updated, cmd := m.replay.Update(msg)
m.replay = updated.(replayUI.Model)
if config.Global.Replay.SwitchToPageOnSend {
m.page = pageReplay
m.resizeChildren()
} else {
return m, tea.Batch(cmd, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Replay",
Body: "Request queued in replay",
Kind: notificationsUI.KindInfo,
}
})
}
return m, cmd
case diffUI.SendToDiffMsg:
updated, cmd := m.diff.Update(msg)
m.diff = updated.(diffUI.Model)
return m, cmd
case diffUI.DiffReadyMsg:
m.page = pageDiff
m.resizeChildren()
return m, nil
case historyUI.EntriesLoadedMsg:
updated, cmd := m.history.Update(msg)
m.history = updated.(historyUI.Model)
return m, cmd
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() {
m.pluginManager.RunOnQuit()
return m, tea.Quit
}
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")
return m, tea.ExecProcess(exec.Command(editor, logPath), nil)
}
if !m.activeIsEditing() {
switch {
case key.Matches(msg, keys.Keys.Global.CopyRequest):
if m.page == pageDiff {
if raw := m.diff.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: "https",
})
}
} else if m.page == pageIntercept {
if raw := m.intercept.CurrentRaw(); raw != "" {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{
RawRequest: raw,
Scheme: m.intercept.CurrentScheme(),
})
}
}
return m, nil
case key.Matches(msg, keys.Keys.Global.ToggleSidebar):
m.cycleSidebarState()
m.resizeChildren()
default:
if p, ok := pageShortcuts[msg.String()]; ok {
prev := m.page
m.page = p
if p == pageHistory && prev != pageHistory {
return m, m.history.RefreshCmd()
}
if p == pageFindings {
return m, findingsUI.RefreshCmd(m.database)
}
}
}
}
}
var cmd tea.Cmd
m, cmd = m.updateActivePage(msg)
return m, cmd
}
func (m Model) activeIsEditing() bool {
for _, e := range pageRegistry {
if e.id == m.page && e.isEditing != nil {
return e.isEditing(&m)
}
}
return false
}
func (m Model) updateActivePage(msg tea.Msg) (Model, tea.Cmd) {
for _, e := range pageRegistry {
if e.id == m.page && e.update != nil {
cmd := e.update(&m, msg)
return m, cmd
}
}
return m, nil
}
func (m *Model) resizeChildren() {
sidebarW := m.getSidebarWidth()
h := m.height
for _, e := range pageRegistry {
if e.resize == nil {
continue
}
e.resize(m, m.width-sidebarW, h)
}
m.notifications.SetSize(m.width, m.height)
}
+49
View File
@@ -0,0 +1,49 @@
package app
import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
v := tea.NewView("")
v.AltScreen = true
return v
}
normal := m.renderNormal()
if m.copyAs.IsOpen() {
v := tea.NewView(m.copyAs.View(normal))
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
rendered := normal
if m.notifications.HasNotifications() {
rendered = m.notifications.View(normal)
}
v := tea.NewView(rendered)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNormal() string {
sidebar := m.renderSidebar()
content := m.renderActivePage()
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
}
func (m *Model) renderActivePage() string {
for _, e := range pageRegistry {
if e.id == m.page && e.render != nil {
return e.render(m)
}
}
return style.S.Faint.Render("Work in progress")
}
+200
View File
@@ -0,0 +1,200 @@
package copyas
import (
"fmt"
"strings"
)
type header struct{ key, value string }
type parsedRequest struct {
method string
path string
host string
scheme string
headers []header
body string
}
func parseRaw(raw, scheme string) parsedRequest {
lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
pr := parsedRequest{scheme: scheme}
if len(lines) == 0 {
return pr
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
pr.method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
pr.path = strings.TrimSpace(parts[1])
}
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
pr.headers = append(pr.headers, header{k, v})
if strings.EqualFold(k, "host") {
pr.host = v
}
}
i++
}
if i < len(lines) {
pr.body = strings.TrimRight(strings.Join(lines[i:], "\n"), "\n")
}
return pr
}
func (pr parsedRequest) fullURL() string {
scheme := pr.scheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + pr.host + pr.path
}
func formatAs(id, raw, scheme string) string {
pr := parseRaw(raw, scheme)
switch id {
case "curl":
return toCurl(pr)
case "python":
return toPython(pr)
case "go":
return toGo(pr)
case "ffuf":
return toFFUF(pr)
case "markdown":
return toMarkdown(pr)
}
return raw
}
func toMarkdown(pr parsedRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "### %s %s\n\n", pr.method, pr.fullURL())
sb.WriteString("```\n")
sb.WriteString(pr.method + " " + pr.path + " HTTP/1.1\n")
for _, h := range pr.headers {
sb.WriteString(fmt.Sprintf("%s: %s\n", h.key, h.value))
}
if pr.body != "" {
sb.WriteString("\n" + pr.body)
}
sb.WriteString("\n```")
return sb.String()
}
func toCurl(pr parsedRequest) string {
var sb strings.Builder
fmt.Fprintf(&sb, "curl -X %s '%s'", pr.method, pr.fullURL())
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value)
}
if pr.body != "" {
body := strings.ReplaceAll(pr.body, "'", "'\\''")
fmt.Fprintf(&sb, " \\\n --data '%s'", body)
}
return sb.String()
}
func toPython(pr parsedRequest) string {
var sb strings.Builder
sb.WriteString("import requests\n\n")
fmt.Fprintf(&sb, "url = %q\n", pr.fullURL())
sb.WriteString("headers = {\n")
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " %q: %q,\n", h.key, h.value)
}
sb.WriteString("}\n")
method := strings.ToLower(pr.method)
if pr.body != "" {
fmt.Fprintf(&sb, "data = %q\n\n", pr.body)
fmt.Fprintf(&sb, "response = requests.%s(url, headers=headers, data=data)\n", method)
} else {
fmt.Fprintf(&sb, "\nresponse = requests.%s(url, headers=headers)\n", method)
}
sb.WriteString("print(response.status_code)\n")
sb.WriteString("print(response.text)\n")
return sb.String()
}
func toGo(pr parsedRequest) string {
var sb strings.Builder
sb.WriteString("package main\n\nimport (\n")
if pr.body != "" {
sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n")
} else {
sb.WriteString("\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n")
}
sb.WriteString("func main() {\n")
if pr.body != "" {
fmt.Fprintf(&sb, "\tbody := strings.NewReader(%q)\n", pr.body)
fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, body)\n", pr.method, pr.fullURL())
} else {
fmt.Fprintf(&sb, "\treq, err := http.NewRequest(%q, %q, nil)\n", pr.method, pr.fullURL())
}
sb.WriteString("\tif err != nil { panic(err) }\n")
for _, h := range pr.headers {
if strings.EqualFold(h.key, "host") || strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, "\treq.Header.Set(%q, %q)\n", h.key, h.value)
}
sb.WriteString("\n\tclient := &http.Client{}\n")
sb.WriteString("\tresp, err := client.Do(req)\n")
sb.WriteString("\tif err != nil { panic(err) }\n")
sb.WriteString("\tdefer resp.Body.Close()\n")
sb.WriteString("\tbody2, _ := io.ReadAll(resp.Body)\n")
sb.WriteString("\tfmt.Printf(\"Status: %d\\n\", resp.StatusCode)\n")
sb.WriteString("\tfmt.Println(string(body2))\n")
sb.WriteString("}\n")
return sb.String()
}
func toFFUF(pr parsedRequest) string {
// Place FUZZ in the path: replace query string or append ?FUZZ
fuzzURL := pr.scheme + "://" + pr.host
if idx := strings.Index(pr.path, "?"); idx != -1 {
fuzzURL += pr.path[:idx] + "?FUZZ"
} else {
fuzzURL += pr.path + "?FUZZ"
}
var sb strings.Builder
fmt.Fprintf(&sb, "ffuf -u '%s'", fuzzURL)
sb.WriteString(" \\\n -w wordlist.txt")
fmt.Fprintf(&sb, " \\\n -X %s", pr.method)
for _, h := range pr.headers {
if strings.EqualFold(h.key, "content-length") {
continue
}
fmt.Fprintf(&sb, " \\\n -H '%s: %s'", h.key, h.value)
}
if pr.body != "" {
body := strings.ReplaceAll(pr.body, "'", "'\\''")
fmt.Fprintf(&sb, " \\\n -d '%s'", body)
}
return sb.String()
}
+117
View File
@@ -0,0 +1,117 @@
package copyas
import (
"encoding/base64"
"fmt"
"os"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
)
const popupInnerW = 46
// writeClipboard uses the OSC 52 terminal escape sequence to set the clipboard.
// Supported by most modern terminals (foot, kitty, wezterm, alacritty, xterm…).
func writeClipboard(text string) {
encoded := base64.StdEncoding.EncodeToString([]byte(text))
fmt.Fprintf(os.Stderr, "\033]52;c;%s\a", encoded)
}
type OpenMsg struct {
RawRequest string
Scheme string
}
type formatItem struct {
id string
title string
desc string
}
func (f formatItem) Title() string { return f.title }
func (f formatItem) Description() string { return f.desc }
func (f formatItem) FilterValue() string { return f.title }
var allFormats = []list.Item{
formatItem{"curl", "cURL", "command line HTTP request"},
formatItem{"python", "Python", "requests library"},
formatItem{"go", "Go", "net/http package"},
formatItem{"ffuf", "FFUF", "web fuzzer: FUZZ in query string"},
formatItem{"markdown", "Markdown", "formatted for documentation"},
}
type Model struct {
open bool
list list.Model
rawRequest string
scheme string
width int
height int
}
func New() Model {
s := style.S
delegate := list.NewDefaultDelegate()
delegate.SetSpacing(0)
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(s.Text).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(s.Subtle).PaddingLeft(2)
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(1)
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(1)
l := list.New(allFormats, delegate, popupInnerW, 8)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
return Model{list: l}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsOpen() bool { return m.open }
func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme
m.open = true
m.list.ResetFilter()
m.list.Select(0)
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.list.SetSize(popupInnerW, m.listHeight())
}
func (m Model) popupHeight() int {
h := 14
if m.height > 0 && m.height-4 < h {
h = m.height - 4
}
if h < 6 {
h = 6
}
return h
}
// listHeight = panel content area - hint line (1)
func (m Model) listHeight() int {
return style.PanelContentH(m.popupHeight()) - 1
}
+30
View File
@@ -0,0 +1,30 @@
package copyas
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if kp, ok := msg.(tea.KeyPressMsg); ok {
switch {
case kp.String() == "enter":
if item, ok := m.list.SelectedItem().(formatItem); ok {
writeClipboard(formatAs(item.id, m.rawRequest, m.scheme))
}
m.open = false
return m, nil
case key.Matches(kp, keys.Keys.Global.Escape):
if m.list.SettingFilter() {
break
}
m.open = false
return m, nil
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
+93
View File
@@ -0,0 +1,93 @@
package copyas
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
func (m *Model) View(background string) string {
s := style.S
hint := lipgloss.NewStyle().Foreground(s.Subtle).
Render(" enter: copy • /: filter • esc: cancel")
inner := lipgloss.JoinVertical(lipgloss.Left,
m.list.View(),
hint,
)
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary)
popupH := m.popupHeight()
popup := style.RenderWithTitle(border, "Copy as", inner, popupInnerW+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)
for y := 0; y < h; y++ {
var line []rune
if y < len(rawLines) {
line = []rune(rawLines[y])
}
if len(line) > w {
line = line[:w]
}
for len(line) < w {
line = append(line, ' ')
}
bgRunes[y] = line
}
popupLines := strings.Split(popup, "\n")
popupH := len(popupLines)
popupW := 0
for _, l := range popupLines {
if vw := lipgloss.Width(l); vw > popupW {
popupW = vw
}
}
startY := (h - popupH) / 2
startX := (w - popupW) / 2
if startY < 0 {
startY = 0
}
if startX < 0 {
startX = 0
}
dim := lipgloss.NewStyle().Foreground(s.Subtle).Faint(true)
result := make([]string, h)
for y := 0; y < h; y++ {
popupY := y - startY
if popupY >= 0 && popupY < popupH {
leftEnd := startX
if leftEnd > len(bgRunes[y]) {
leftEnd = len(bgRunes[y])
}
prefix := dim.Render(string(bgRunes[y][:leftEnd]))
rightStart := startX + popupW
suffix := ""
if rightStart < len(bgRunes[y]) {
suffix = dim.Render(string(bgRunes[y][rightStart:]))
}
result[y] = prefix + popupLines[popupY] + suffix
} else {
result[y] = dim.Render(string(bgRunes[y]))
}
}
return strings.Join(result, "\n")
}
@@ -0,0 +1,155 @@
package notifications
import (
"image/color"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/charmbracelet/x/ansi"
)
type Kind string
const (
KindInfo Kind = "info"
KindSuccess Kind = "success"
KindWarning Kind = "warning"
KindError Kind = "error"
)
type NotificationMsg struct {
Title string
Body string
Kind Kind
}
type DismissMsg struct{ ID int }
type notification struct {
id int
title string
body string
kind Kind
}
type Model struct {
queue []notification
nextID int
width int
height int
}
func New() Model { return Model{} }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
}
func (m Model) HasNotifications() bool {
return len(m.queue) > 0
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case NotificationMsg:
n := notification{id: m.nextID, title: msg.Title, body: msg.Body, kind: msg.Kind}
m.nextID++
m.queue = append(m.queue, n)
return m, tea.Tick(4*time.Second, func(time.Time) tea.Msg { return DismissMsg{ID: n.id} })
case DismissMsg:
for i, n := range m.queue {
if n.id == msg.ID {
m.queue = append(m.queue[:i], m.queue[i+1:]...)
break
}
}
}
return m, nil
}
func (m Model) View(background string) string {
if len(m.queue) == 0 {
return background
}
s := style.S
const popupW = 34
var popups []string
start := len(m.queue) - 3
if start < 0 {
start = 0
}
for i := start; i < len(m.queue); i++ {
n := m.queue[i]
var accent color.Color
switch n.kind {
case KindSuccess:
accent = s.Success
case KindWarning:
accent = s.Warning
case KindError:
accent = s.Error
default:
accent = s.Primary
}
titleStr := lipgloss.NewStyle().Foreground(accent).Bold(true).Render(n.title)
bodyStr := lipgloss.NewStyle().Foreground(s.Text).Width(popupW).Render(n.body)
inner := lipgloss.JoinVertical(lipgloss.Left, titleStr, bodyStr)
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(0, 1).
Render(inner)
popups = append(popups, box)
}
popup := strings.Join(popups, "\n")
return overlayTopRight(background, popup, m.width, m.height)
}
func overlayTopRight(bg, popup string, w, h int) string {
bgLines := strings.Split(bg, "\n")
popupLines := strings.Split(popup, "\n")
popupH := len(popupLines)
popupW := 0
for _, l := range popupLines {
if vw := lipgloss.Width(l); vw > popupW {
popupW = vw
}
}
const marginTop = 1
const marginRight = 2
startY := marginTop
startX := w - popupW - marginRight
if startX < 0 {
startX = 0
}
result := make([]string, h)
for y := 0; y < h; y++ {
bgLine := ""
if y < len(bgLines) {
bgLine = bgLines[y]
}
popupY := y - startY
if popupY >= 0 && popupY < popupH {
prefix := ansi.Truncate(bgLine, startX, "")
suffix := ansi.TruncateLeft(bgLine, startX+popupW, "")
result[y] = prefix + popupLines[popupY] + suffix
} else {
result[y] = bgLine
}
}
return strings.Join(result, "\n")
}
+72
View File
@@ -0,0 +1,72 @@
package teapot
import "strings"
// FrameLines returns the number of visual lines in a teapot frame.
func FrameLines() int {
frames := TeapotFrames()
if len(frames) == 0 {
return 0
}
return strings.Count(frames[0], "\n") + 1
}
func Teapot() string {
return "" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ "
}
func TeapotFrames() []string {
return []string{
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" ) \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" \n" +
" ( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
"" +
" \n" +
" (( \n" +
" ) \n" +
" .-.,--^--. _ \n" +
" \\\\| `---' |//\n" +
" \\| / \n" +
" _\\_______/_ ",
}
}
+264
View File
@@ -0,0 +1,264 @@
package diff
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type slot struct {
label string
raw string
}
type focusedSlot int
const (
bothSlots focusedSlot = iota
leftSlot
rightSlot
)
func (f focusedSlot) next() focusedSlot {
return (f + 1) % 3
}
type lineKind int
const (
lineUnchanged lineKind = iota
lineAdded
lineRemoved
)
type diffLine struct {
text string
kind lineKind
}
type Model struct {
left slot
right slot
focus focusedSlot
leftLines []diffLine
rightLines []diffLine
leftViewport viewport.Model
rightViewport viewport.Model
help help.Model
width int
height int
}
func New() Model {
return Model{
leftViewport: style.NewViewport(),
rightViewport: style.NewViewport(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
// CurrentRaw returns the raw content of the focused slot (left when both are focused).
func (m Model) CurrentRaw() string {
if m.focus == rightSlot {
return m.right.raw
}
return m.left.raw
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
if panelH < 0 {
panelH = 0
}
leftW := m.width / 2
rightW := m.width - leftW
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
viewportH := style.PanelContentH(panelH)
m.leftViewport.SetWidth(leftInner)
m.leftViewport.SetHeight(viewportH)
m.rightViewport.SetWidth(rightInner)
m.rightViewport.SetHeight(viewportH)
m.refreshViewports()
}
func (m *Model) computeDiff() {
if m.left.raw == "" || m.right.raw == "" {
m.leftLines = nil
m.rightLines = nil
return
}
leftNorm := normRaw(m.left.raw)
rightNorm := normRaw(m.right.raw)
leftPlain := strings.Split(leftNorm, "\n")
rightPlain := strings.Split(rightNorm, "\n")
leftHL := hlLines(leftNorm)
rightHL := hlLines(rightNorm)
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
}
func normRaw(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.TrimRight(s, "\n")
}
func hlLines(raw string) []string {
s := strings.TrimRight(style.HighlightHTTP(raw), "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
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(" <(^_^)>\nsend two entries here to compare"),
)
m.leftViewport.SetContent(placeholder)
m.rightViewport.SetContent("")
return
}
if m.right.raw == "" {
m.leftViewport.SetContent(style.HighlightHTTP(normRaw(m.left.raw)))
placeholder := lipgloss.Place(
m.rightViewport.Width(), m.rightViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(" (・3・)\nwaiting for second entry…"),
)
m.rightViewport.SetContent(placeholder)
return
}
m.leftViewport.SetContent(renderLeftLines(m.leftLines))
m.rightViewport.SetContent(renderRightLines(m.rightLines))
}
func (m *Model) scroll(delta int) {
offset := m.leftViewport.YOffset() + delta
m.leftViewport.SetYOffset(offset)
m.rightViewport.SetYOffset(offset)
}
func (m *Model) scrollH(delta int) {
offset := m.leftViewport.XOffset() + delta
m.leftViewport.SetXOffset(offset)
m.rightViewport.SetXOffset(offset)
}
func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
hlA := func(i int) string {
if i < len(aHL) {
return aHL[i]
}
return a[i]
}
hlB := func(j int) string {
if j < len(bHL) {
return bHL[j]
}
return b[j]
}
n, m := len(a), len(b)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := 1; i <= n; i++ {
for j := 1; j <= m; j++ {
if a[i-1] == b[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] >= dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
left = make([]diffLine, 0, n+m)
right = make([]diffLine, 0, n+m)
i, j := n, m
for i > 0 || j > 0 {
switch {
case i > 0 && j > 0 && a[i-1] == b[j-1]:
left = append(left, diffLine{text: hlA(i-1), kind: lineUnchanged})
right = append(right, diffLine{text: hlB(j-1), kind: lineUnchanged})
i--
j--
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
left = append(left, diffLine{kind: lineAdded})
right = append(right, diffLine{text: hlB(j-1), kind: lineAdded})
j--
default:
left = append(left, diffLine{text: hlA(i-1), kind: lineRemoved})
right = append(right, diffLine{kind: lineRemoved})
i--
}
}
for lo, hi := 0, len(left)-1; lo < hi; lo, hi = lo+1, hi-1 {
left[lo], left[hi] = left[hi], left[lo]
right[lo], right[hi] = right[hi], right[lo]
}
return left, right
}
func diffBindings() []key.Binding {
g := keys.Keys.Global
return []key.Binding{
g.Up, g.Down, g.ScrollUp, g.ScrollDown,
g.CycleFocus, keys.Keys.Diff.Clear,
}
}
type diffKeyMap struct{ width int }
func (diffKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
return []key.Binding{g.Up, g.Down, g.CycleFocus, keys.Keys.Diff.Clear, g.Help}
}
func (m diffKeyMap) FullHelp() [][]key.Binding {
all := append(diffBindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+143
View File
@@ -0,0 +1,143 @@
package diff
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
)
// SendToDiffMsg carries a raw HTTP request or response to the diff page.
type SendToDiffMsg struct {
Label string
Raw string
}
// DiffReadyMsg is emitted when both slots are filled and the diff is ready to view.
type DiffReadyMsg struct{}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case SendToDiffMsg:
if m.left.raw == "" {
m.left = slot{label: msg.Label, raw: msg.Raw}
m.refreshViewports()
return m, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Entry selected",
Body: "Select a second entry to compare",
Kind: notificationsUI.KindInfo,
}
}
} else if m.right.raw == "" {
m.right = slot{label: msg.Label, raw: msg.Raw}
m.computeDiff()
m.focus = bothSlots
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
return m, func() tea.Msg { return DiffReadyMsg{} }
} else {
// Both full: reset and start new comparison
m.left = slot{label: msg.Label, raw: msg.Raw}
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
return m, func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: "Entry replaced",
Body: "Select a second entry to compare",
Kind: notificationsUI.KindInfo,
}
}
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.scrollH(-6)
} else {
m.scroll(-1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.scrollH(6)
} else {
m.scroll(1)
}
case tea.MouseWheelLeft:
m.scrollH(-6)
case tea.MouseWheelRight:
m.scrollH(6)
}
case tea.KeyPressMsg:
switch {
case key.Matches(msg, keys.Keys.Global.CycleFocus):
m.focus = m.focus.next()
case key.Matches(msg, keys.Keys.Global.Up):
m.scroll(-1)
case key.Matches(msg, keys.Keys.Global.Down):
m.scroll(1)
case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.leftViewport.Height() / 2
if step < 1 {
step = 1
}
m.scroll(-step)
case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.leftViewport.Height() / 2
if step < 1 {
step = 1
}
m.scroll(step)
case key.Matches(msg, keys.Keys.Global.Left):
m.scrollH(-6)
case key.Matches(msg, keys.Keys.Global.Right):
m.scrollH(6)
case key.Matches(msg, keys.Keys.Diff.Clear):
switch m.focus {
case leftSlot:
m.left = m.right
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
case rightSlot:
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
default:
m.left = slot{}
m.right = slot{}
m.leftLines = nil
m.rightLines = nil
m.focus = bothSlots
}
m.leftViewport.SetYOffset(0)
m.rightViewport.SetYOffset(0)
m.leftViewport.SetXOffset(0)
m.rightViewport.SetXOffset(0)
m.refreshViewports()
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
+94
View File
@@ -0,0 +1,94 @@
package diff
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderPanels(panelH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderPanels(panelH int) string {
s := style.S
leftW := m.width / 2
rightW := m.width - leftW
leftTitle := icons.I.Diff + "First"
if m.left.label != "" {
leftTitle = icons.I.Diff + "First: " + m.left.label
}
rightTitle := icons.I.Diff + "Second"
if m.right.label != "" {
rightTitle = icons.I.Diff + "Second: " + m.right.label
}
leftBorder := s.Panel
rightBorder := s.Panel
switch m.focus {
case bothSlots:
leftBorder = s.PanelFocused
rightBorder = s.PanelFocused
case leftSlot:
leftBorder = s.PanelFocused
case rightSlot:
rightBorder = s.PanelFocused
}
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(diffKeyMap{width: m.width}))
}
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")
case lineAdded:
sb.WriteString("\n")
default:
sb.WriteString(" " + l.text + "\n")
}
}
return sb.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")
case lineRemoved:
sb.WriteString("\n")
default:
sb.WriteString(" " + l.text + "\n")
}
}
return sb.String()
}
+37
View File
@@ -0,0 +1,37 @@
package docs
import (
"strings"
spilltea "github.com/anotherhadi/spilltea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
)
func readDoc(name string) string {
b, _ := spilltea.DocsFS.ReadFile(".github/docs/" + name)
return string(b)
}
var contentMarkdown = strings.Join([]string{
readDoc("main.md"),
readDoc("proxy.md"),
readDoc("certificate.md"),
readDoc("history.md"),
readDoc("scopes.md"),
}, "\n")
type Model struct {
viewport viewport.Model
}
func New() Model {
return Model{
viewport: viewport.New(),
}
}
func (e Model) Init() tea.Cmd {
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package docs
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
switch msg := msg.(type) {
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case tea.MouseWheelDown:
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
}
case tea.KeyPressMsg:
switch {
case key.Matches(msg, g.Up):
e.viewport.SetYOffset(e.viewport.YOffset() - 1)
case key.Matches(msg, g.Down):
e.viewport.SetYOffset(e.viewport.YOffset() + 1)
case key.Matches(msg, g.ScrollUp):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := e.viewport.Height() / 2
if step < 1 {
step = 1
}
e.viewport.SetYOffset(e.viewport.YOffset() + step)
}
}
return e, nil
}
func (m *Model) SetSize(w, h int) {
frameW := windowStyle().GetHorizontalFrameSize()
frameH := windowStyle().GetVerticalFrameSize()
m.viewport.SetWidth(w - frameW)
m.viewport.SetHeight(h - frameH)
m.renderMarkdown()
}
+52
View File
@@ -0,0 +1,52 @@
package docs
import (
"bytes"
_ "embed"
"text/template"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/style"
)
func windowStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(style.S.Subtle).
Padding(0, 0)
}
func (e Model) View() tea.View {
return tea.NewView(windowStyle().Render(e.viewport.View()))
}
func (m *Model) renderMarkdown() {
cfg := config.Global
data := struct {
Cfg *config.Config
}{
Cfg: cfg,
}
tmpl, err := template.New("info").Parse(contentMarkdown)
if err != nil {
return
}
var processed bytes.Buffer
if err := tmpl.Execute(&processed, data); err != nil {
return
}
width := m.viewport.Width() - 2
renderer, _ := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(cfg)),
glamour.WithWordWrap(width),
)
str, _ := renderer.Render(processed.String())
m.viewport.SetContent(str)
}
+156
View File
@@ -0,0 +1,156 @@
package findings
import (
"bytes"
"text/template"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
database *db.DB
findings []db.Finding
cursor int
listViewport viewport.Model
bodyViewport viewport.Model
pager paginator.Model
help help.Model
width int
height int
}
func New() Model {
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetDB(d *db.DB) {
m.database = d
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
inner := m.width - 2
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(findingsKeyMap{}))
}
// RefreshCmd loads findings from the database.
func RefreshCmd(d *db.DB) tea.Cmd {
return func() tea.Msg {
if d == nil {
return FindingsLoadedMsg{}
}
list, err := d.LoadFindings()
if err != nil {
return FindingsLoadedMsg{Err: err}
}
return FindingsLoadedMsg{Findings: list}
}
}
type FindingsLoadedMsg struct {
Findings []db.Finding
Err error
}
func (m *Model) refreshBody() {
if len(m.findings) == 0 {
m.bodyViewport.SetContent("")
return
}
f := m.findings[m.cursor]
rendered := renderMarkdown(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered)
m.bodyViewport.GotoTop()
}
func renderMarkdown(src string, width int) string {
if src == "" {
return style.S.Faint.Render(" (ㆆ _ ㆆ)\nno description")
}
tmpl, err := template.New("").Parse(src)
if err != nil {
return src
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, nil); err != nil {
return src
}
if width < 10 {
width = 80
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
)
if err != nil {
return buf.String()
}
out, err := r.Render(buf.String())
if err != nil {
return buf.String()
}
return out
}
type findingsKeyMap struct{}
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss}
}
func (findingsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{findingsKeyMap{}.ShortHelp()}
}
+86
View File
@@ -0,0 +1,86 @@
package findings
import (
"log"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case FindingsLoadedMsg:
if msg.Err != nil {
log.Printf("findings load error: %v", msg.Err)
return m, nil
}
m.findings = msg.Findings
if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1)
}
m.pager.SetTotalPages(len(m.findings))
m.refreshListViewport()
m.refreshBody()
return m, nil
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
return m, nil
case tea.KeyPressMsg:
g := keys.Keys.Global
f := keys.Keys.Findings
switch {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
if m.cursor < m.pager.Page*m.pager.PerPage {
m.pager.PrevPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.findings)-1 {
m.cursor++
if m.cursor >= (m.pager.Page+1)*m.pager.PerPage {
m.pager.NextPage()
}
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
log.Printf("dismiss finding: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
}
}
return m, nil
}
func (m *Model) refreshListViewport() {
m.listViewport.SetContent(m.renderList())
}
+112
View File
@@ -0,0 +1,112 @@
package findings
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := 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)
}
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)
}
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(" (҂◡_◡) ᕤ\nno findings"),
)
}
start, end := m.pager.GetSliceBounds(len(m.findings))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, f := range m.findings[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05")
w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW
if titleW < 0 {
titleW = 0
}
pluginStr := s.Faint.Width(8).Render(util.Truncate(f.PluginName, 8))
var line string
if selected {
bg := lipgloss.NewStyle().Background(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.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(titleW).Render(f.Title),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
sevLabel,
" ",
pluginStr,
" ",
s.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(f.Title),
)
}
sb.WriteString(fmt.Sprintf("%s\n", line))
}
return sb.String()
}
+149
View File
@@ -0,0 +1,149 @@
package history
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type panel int
const (
panelRequest panel = iota
panelResponse
)
type Model struct {
database *db.DB
entries []db.Entry
cursor int
focusedPanel panel
listViewport viewport.Model
bodyViewport viewport.Model
pager paginator.Model
help help.Model
searchInput textinput.Model
searchKind searchKind
searchAccepted bool
searchErr string
width int
height int
}
func New() Model {
ti := textinput.New()
ti.Prompt = ""
return Model{
listViewport: style.NewViewport(),
bodyViewport: style.NewViewport(),
pager: style.NewPaginator(),
help: style.NewHelp(),
searchInput: ti,
}
}
func (m Model) IsEditing() bool {
return m.searchKind != searchKindOff && !m.searchAccepted
}
// RefreshCmd returns the appropriate load command given the current search state.
// The app model should call this instead of LoadEntriesCmd directly so that
// background refreshes re-run the active search rather than resetting it.
func (m Model) RefreshCmd() tea.Cmd {
switch m.searchKind {
case searchKindFulltext:
return SearchCmd(m.database, m.searchInput.Value())
case searchKindSQL:
return nil
default:
return LoadEntriesCmd(m.database)
}
}
func (m *Model) clearSearch() tea.Cmd {
m.searchKind = searchKindOff
m.searchAccepted = false
m.searchErr = ""
m.searchInput.SetValue("")
m.searchInput.Blur()
m.recalcSizes()
return LoadEntriesCmd(m.database)
}
func (m *Model) acceptSearch() {
m.searchAccepted = true
m.searchInput.Blur()
m.recalcSizes()
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetDB(d *db.DB) {
m.database = d
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
// 2 (padding) + 2 (prefix char + space)
m.searchInput.SetWidth(m.width - 4)
listH, bodyH := style.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
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
bodyVH := style.PanelContentH(bodyH)
m.bodyViewport.SetWidth(inner)
m.bodyViewport.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
type historyKeyMap struct{ width int }
func (historyKeyMap) ShortHelp() []key.Binding {
h := keys.Keys.History
g := keys.Keys.Global
return []key.Binding{
g.Up, g.Down, g.CycleFocus,
h.DeleteEntry, h.DeleteAll,
h.Filter, h.SqlQuery,
g.Help,
}
}
func (m historyKeyMap) FullHelp() [][]key.Binding {
h := keys.Keys.History
all := []key.Binding{h.DeleteEntry, h.DeleteAll, h.Filter, h.SqlQuery}
all = append(all, keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+48
View File
@@ -0,0 +1,48 @@
package history
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
)
type searchKind int
const (
searchKindOff searchKind = iota
searchKindFulltext
searchKindSQL
)
type SearchResultMsg struct {
Entries []db.Entry
}
type SearchErrMsg struct {
Err error
}
func SearchCmd(database *db.DB, term string) tea.Cmd {
return func() tea.Msg {
if database == nil {
return SearchResultMsg{}
}
entries, err := database.SearchEntries(term)
if err != nil {
return SearchErrMsg{Err: err}
}
return SearchResultMsg{Entries: entries}
}
}
func SQLCmd(database *db.DB, query string) tea.Cmd {
return func() tea.Msg {
if database == nil {
return SearchResultMsg{}
}
entries, err := database.QueryEntries(query)
if err != nil {
return SearchErrMsg{Err: err}
}
return SearchResultMsg{Entries: entries}
}
}
+303
View File
@@ -0,0 +1,303 @@
package history
import (
"fmt"
"net/http"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
"github.com/anotherhadi/spilltea/internal/util"
)
type EntriesLoadedMsg struct {
Entries []db.Entry
}
func LoadEntriesCmd(database *db.DB) tea.Cmd {
return func() tea.Msg {
if database == nil {
return EntriesLoadedMsg{}
}
entries, _ := database.ListEntries()
return EntriesLoadedMsg{Entries: entries}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case EntriesLoadedMsg:
// Ignore background reloads while a search is active (but not during a mode switch reset).
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
return m, nil
}
prevCursor := m.cursor
m.entries = msg.Entries
if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
if m.cursor != prevCursor {
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case SearchResultMsg:
m.entries = msg.Entries
m.cursor = 0
m.searchErr = ""
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
if m.searchKind == searchKindSQL {
m.acceptSearch()
}
case SearchErrMsg:
m.searchErr = msg.Err.Error()
m.entries = nil
m.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
case tea.KeyPressMsg:
h := keys.Keys.History
g := keys.Keys.Global
if m.searchKind != searchKindOff && !m.searchAccepted {
// Actively typing: only search navigation + accept/cancel.
switch {
case key.Matches(msg, g.Escape):
return m, m.clearSearch()
case msg.String() == "enter":
if m.searchKind == searchKindSQL {
return m, SQLCmd(m.database, m.searchInput.Value())
}
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:
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
if m.searchKind == searchKindFulltext {
return m, tea.Batch(cmd, SearchCmd(m.database, m.searchInput.Value()))
}
return m, cmd
}
return m, nil
}
if m.searchKind != searchKindOff && m.searchAccepted {
// Filter accepted: Escape clears, all other shortcuts fall through.
if key.Matches(msg, g.Escape) {
return m, m.clearSearch()
}
}
switch {
case key.Matches(msg, keys.Keys.History.Filter):
prev := m.searchKind
m.searchKind = searchKindFulltext
m.searchAccepted = false
m.searchInput.Placeholder = "filter requests..."
m.searchErr = ""
m.searchInput.Focus()
m.recalcSizes()
if prev != searchKindFulltext {
m.searchInput.SetValue("")
return m, LoadEntriesCmd(m.database)
}
case key.Matches(msg, keys.Keys.History.SqlQuery):
prev := m.searchKind
m.searchKind = searchKindSQL
m.searchAccepted = false
m.searchInput.Placeholder = "status_code = 200 AND host LIKE '%.api.%'"
m.searchErr = ""
m.searchInput.Focus()
m.recalcSizes()
if prev != searchKindSQL {
m.searchInput.SetValue("")
return m, LoadEntriesCmd(m.database)
}
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)
}
case key.Matches(msg, g.CycleFocus):
if m.focusedPanel == panelRequest {
m.focusedPanel = panelResponse
} else {
m.focusedPanel = panelRequest
}
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
case key.Matches(msg, g.SendToReplay):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
scheme := util.InferScheme(e.Host)
return m, func() tea.Msg {
return replayUI.SendToReplayMsg{
Scheme: scheme,
Host: e.Host,
RequestRaw: e.RequestRaw,
}
}
}
case key.Matches(msg, g.SendToDiff):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
var raw, label string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
} else {
raw = e.RequestRaw
label = e.Method + " " + e.Host + e.Path
}
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
case key.Matches(msg, h.DeleteEntry):
if len(m.entries) > 0 {
id := m.entries[m.cursor].ID
if m.database != nil {
m.database.DeleteEntry(id)
}
return m, LoadEntriesCmd(m.database)
}
case key.Matches(msg, h.DeleteAll):
if m.database != nil {
if m.searchKind != searchKindOff {
for _, e := range m.entries {
m.database.DeleteEntry(e.ID)
}
} else {
m.database.DeleteAllEntries()
}
}
return m, m.clearSearch()
case key.Matches(msg, g.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, g.Left):
m.bodyViewport.ScrollLeft(6)
case key.Matches(msg, g.Right):
m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshBody() {
if len(m.entries) == 0 {
m.bodyViewport.SetContent("")
return
}
e := m.entries[m.cursor]
var raw string
if m.focusedPanel == panelResponse {
raw = e.ResponseRaw
} else {
raw = e.RequestRaw
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
}
+150
View File
@@ -0,0 +1,150 @@
package history
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := 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)
}
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)
}
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"))
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())
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"))
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())
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),
)
}
if len(m.entries) == 0 {
msg := " (⌐■_■)\nno history yet"
if m.searchKind != searchKindOff {
msg = "ʕノ•ᴥ•ʔノ ︵ ┻━┻\n no results"
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
)
}
start, end := m.pager.GetSliceBounds(len(m.entries))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, e := range m.entries[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
statusStr := fmt.Sprintf("%3d", e.StatusCode)
const fixedW = 2 + 7 + 1 + 3 + 1 + 10 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
ts := e.Timestamp.Format("15:04:05")
statusSt := style.StatusStyle(e.StatusCode, 3)
var line 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.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Foreground(s.Subtle).Width(10).Render(ts),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Faint.Width(10).Render(ts),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
+339
View File
@@ -0,0 +1,339 @@
package home
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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"
)
type itemKind int
const (
kindNew itemKind = iota
kindTemp
kindExisting
)
type listItem struct {
kind itemKind
name string
path string
count int
modTime time.Time
}
func (i listItem) icon() string {
ic := icons.I
switch i.kind {
case kindNew:
return ic.New
case kindTemp:
return ic.Temp
default:
return ic.Project
}
}
func (i listItem) title() string {
switch i.kind {
case kindNew:
return "New Project"
case kindTemp:
return "Temporary Session"
default:
return i.name
}
}
func (i listItem) description() string {
switch i.kind {
case kindNew:
return "create and name a new project"
case kindTemp:
return "isolated session, deleted on exit"
default:
date := i.modTime.Format("Jan 2, 2006")
if i.count == 1 {
return fmt.Sprintf("1 request · %s", date)
}
return fmt.Sprintf("%d requests · %s", i.count, date)
}
}
// FilterValue contains only the text (no icon) so fuzzy match indices map
// directly onto title() and don't need an offset to account for icon width.
func (i listItem) FilterValue() string { return i.title() }
type homeDelegate struct {
normalTitle lipgloss.Style
normalDesc lipgloss.Style
selectedTitle lipgloss.Style
selectedDesc lipgloss.Style
filterMatch lipgloss.Style
}
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),
selectedTitle: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.Primary).Bold(true).PaddingLeft(3),
selectedDesc: lipgloss.NewStyle().
Border(leftBorder, false, false, false, true).
BorderForeground(s.Primary).
Foreground(s.MutedFg).PaddingLeft(3),
filterMatch: lipgloss.NewStyle().Underline(true),
}
}
func (d homeDelegate) Height() int { return 2 }
func (d homeDelegate) Spacing() int { return 1 }
func (d homeDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d homeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
li := item.(listItem)
selected := index == m.Index()
// Apply match highlighting only to the title text
// separately so its width never shifts the highlight indices.
titleText := li.title()
if m.IsFiltered() {
if matches := m.MatchesForItem(index); len(matches) > 0 {
base := lipgloss.NewStyle()
titleText = lipgloss.StyleRunes(titleText, matches, d.filterMatch.Inherit(base), base)
}
}
full := li.icon() + titleText
var titleLine, descLine string
if selected {
titleLine = d.selectedTitle.Render(full)
descLine = d.selectedDesc.Render(li.description())
} else {
titleLine = d.normalTitle.Render(full)
descLine = d.normalDesc.Render(li.description())
}
fmt.Fprintf(w, "%s\n%s", titleLine, descLine)
}
type Project struct {
Name string
Path string
Count int
ModTime time.Time
}
type inputMode int
const (
modeSelect inputMode = iota
modeNaming
)
const (
baseHeaderLines = 1 + 1 + 1 + 2
teapotMinH = 28 // minimum terminal height to show the teapot
maxInnerW = 80 // max content width inside the padding box
maxInnerH = 50 // max content height inside the padding box
)
type Model struct {
mode inputMode
list list.Model
projectDir string
nameInput textinput.Model
selected *Project
width int
height int
teapotFrame int
}
// Selected returns the project chosen by the user, or nil if the program was
// quit without making a selection.
func (m Model) Selected() *Project { return m.selected }
func New(projectDir string) Model {
projects := loadProjects(projectDir)
l := list.New(buildItems(projects), newHomeDelegate(), 0, 0)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.KeyMap.Quit.SetEnabled(false)
l.KeyMap.ForceQuit.SetEnabled(false)
l.KeyMap.ShowFullHelp.SetEnabled(false)
l.KeyMap.CloseFullHelp.SetEnabled(false)
ti := textinput.New()
ti.Placeholder = "my-project"
ti.CharLimit = 64
ti.SetWidth(inputPanelMaxW - 2 - 4)
return Model{
projectDir: projectDir,
list: l,
nameInput: ti,
}
}
func (m Model) Init() tea.Cmd { return teapotTick() }
func (m Model) innerW() int {
w := m.width - 2
if w > maxInnerW {
w = maxInnerW
}
if w < 0 {
return 0
}
return w
}
func (m Model) innerH() int {
h := m.height - 2
if h > maxInnerH {
h = maxInnerH
}
if h < 0 {
return 0
}
return h
}
func (m Model) headerHeight() int {
if m.height > teapotMinH {
// teapot block replaces 1 \n (else branch) with frame \n's + \n\n
// net addition = FrameLines() (= frame_internal_\n + \n\n - else_\n)
return baseHeaderLines + teapot.FrameLines()
}
return baseHeaderLines
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
lw := m.listWidth()
lh := m.innerH() - m.headerHeight() - 1
if lh < 0 {
lh = 0
}
m.list.SetSize(lw, lh)
m.nameInput.SetWidth(inputPanelInnerW(m.innerW()))
}
func (m Model) IsEditing() bool { return m.mode == modeNaming }
func (m Model) listWidth() int {
return m.innerW()
}
func inputPanelInnerW(termW int) int {
panelW := inputPanelMaxW
if termW < panelW+4 {
panelW = termW - 4
}
if panelW < 10 {
panelW = 10
}
return panelW - 2 - 4 // border (2) + padding (2×2)
}
func loadProjects(projectDir string) []Project {
entries, err := os.ReadDir(projectDir)
if err != nil {
return nil
}
var projects []Project
for _, e := range entries {
if !e.IsDir() {
continue
}
dbPath := filepath.Join(projectDir, e.Name(), "data.db")
info, err := os.Stat(dbPath)
if err != nil {
continue
}
projects = append(projects, Project{
Name: e.Name(),
Path: dbPath,
Count: db.CountEntriesAt(dbPath),
ModTime: info.ModTime(),
})
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].ModTime.After(projects[j].ModTime)
})
return projects
}
func buildItems(projects []Project) []list.Item {
items := []list.Item{
listItem{kind: kindNew},
listItem{kind: kindTemp},
}
for _, p := range projects {
items = append(items, listItem{
kind: kindExisting,
name: p.Name,
path: p.Path,
count: p.Count,
modTime: p.ModTime,
})
}
return items
}
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)
sep := s.Faint.Inline(true).Render(" • ")
item := func(keyStr, desc string) string {
return kStyle.Render(keyStr) + " " + dStyle.Render(desc)
}
binding := func(b key.Binding) string {
return item(b.Help().Key, b.Help().Desc)
}
var parts []string
if fs == list.Filtering {
parts = append(parts, item("enter", "apply filter"))
parts = append(parts, item("esc", "cancel"))
} else {
parts = append(parts, item("↑/↓", "navigate"))
if fs == list.FilterApplied {
parts = append(parts, item("esc", "clear filter"))
} else {
parts = append(parts, binding(k.Filter))
}
parts = append(parts, binding(k.Open))
parts = append(parts, binding(k.Delete))
parts = append(parts, item("q", "quit"))
}
return strings.Join(parts, sep)
}
+180
View File
@@ -0,0 +1,180 @@
package home
import (
crypto "crypto/rand"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/ui/components/teapot"
)
type teapotTickMsg struct{}
func teapotTick() tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return teapotTickMsg{}
})
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if ws, ok := msg.(tea.WindowSizeMsg); ok {
m.SetSize(ws.Width, ws.Height)
return m, nil
}
if _, ok := msg.(teapotTickMsg); ok {
frames := teapot.TeapotFrames()
m.teapotFrame = (m.teapotFrame + 1) % len(frames)
return m, teapotTick()
}
if m.mode == modeNaming {
if kp, ok := msg.(tea.KeyPressMsg); ok {
return m.updateNaming(kp)
}
return m, nil
}
if kp, ok := msg.(tea.KeyPressMsg); ok {
if !m.list.SettingFilter() {
if key.Matches(kp, keys.Keys.Global.Quit) {
return m, tea.Quit
}
if key.Matches(kp, keys.Keys.Home.Open) {
return m.handleSelection()
}
if key.Matches(kp, keys.Keys.Home.Delete) {
return m.deleteSelected()
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) handleSelection() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok {
return m, nil
}
switch item.kind {
case kindNew:
m.mode = modeNaming
m.nameInput.SetValue("")
return m, m.nameInput.Focus()
case kindTemp:
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
m.selected = &Project{Name: item.name, Path: item.path}
return m, tea.Quit
}
}
func (m Model) deleteSelected() (tea.Model, tea.Cmd) {
item, ok := m.list.SelectedItem().(listItem)
if !ok || item.kind != kindExisting {
return m, nil
}
dir := filepath.Dir(item.path) // parent dir of data.db
os.RemoveAll(dir)
idx := m.list.GlobalIndex()
m.list.RemoveItem(idx)
if idx > 0 && idx >= len(m.list.Items()) {
m.list.Select(idx - 1)
}
return m, nil
}
func (m Model) updateNaming(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.mode = modeSelect
m.nameInput.Blur()
return m, nil
case msg.String() == "enter":
name := m.nameInput.Value()
if name == "" {
return m, nil
}
m.mode = modeSelect
m.nameInput.Blur()
dir := filepath.Join(m.projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return m, nil
}
initProjectFiles(dir)
m.selected = &Project{Name: name, Path: filepath.Join(dir, "data.db")}
return m, tea.Quit
default:
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
m.nameInput.SetValue(sanitizeName(m.nameInput.Value()))
return m, cmd
}
}
func sanitizeName(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func IsValidProjectName(s string) bool {
if s == "tmp" {
return true
}
return s != "" && s == sanitizeName(s)
}
func OpenProject(projectDir, name string) (*Project, error) {
if name == "tmp" {
dir := tempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: "temporary", Path: filepath.Join(dir, "data.db")}, nil
}
dir := filepath.Join(projectDir, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
initProjectFiles(dir)
return &Project{Name: name, Path: filepath.Join(dir, "data.db")}, nil
}
func tempDir() string {
b := make([]byte, 4)
_, _ = crypto.Read(b)
return filepath.Join(os.TempDir(), "spilltea", fmt.Sprintf("%08x", b))
}
func initProjectFiles(dir string) {
for _, name := range []string{"data.db", "logs.log"} {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); os.IsNotExist(err) {
f, err := os.Create(p)
if err == nil {
f.Close()
}
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package home
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/style"
"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])
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("\n")
sb.WriteString(center(iw, s.Faint.Render("choose a project to get started")))
sb.WriteString("\n\n")
if m.mode == modeNaming {
sb.WriteString(m.renderNamingPanel())
} else {
lw := m.listWidth()
leftPad := (iw - lw) / 2
sb.WriteString(padLeft(m.list.View(), leftPad))
sb.WriteString("\n")
sb.WriteString(center(iw, m.renderHelpLine()))
}
box := lipgloss.NewStyle().Padding(1, 1).Render(sb.String())
content := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
v := tea.NewView(content)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (m Model) renderNamingPanel() string {
s := style.S
iw := m.innerW()
panelW := inputPanelMaxW
if iw < panelW+4 {
panelW = iw - 4
}
if panelW < 10 {
panelW = 10
}
innerW := inputPanelInnerW(iw)
inputLine := lipgloss.NewStyle().Width(innerW).Render(m.nameInput.View())
label := lipgloss.NewStyle().Foreground(s.MutedFg).Render("Project name")
panel := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(s.Primary).
Padding(1, 2).
Width(panelW).
Render(label + "\n" + inputLine)
hint := s.Faint.Render("[enter] confirm [esc] cancel")
var sb strings.Builder
sb.WriteString(center(iw, panel))
sb.WriteString("\n")
sb.WriteString(center(iw, hint))
sb.WriteString("\n")
return sb.String()
}
// padLeft prepends n spaces to every non-empty line.
func padLeft(content string, n int) string {
if n <= 0 {
return content
}
pad := strings.Repeat(" ", n)
lines := strings.Split(content, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
func center(width int, s string) string {
return lipgloss.PlaceHorizontal(width, lipgloss.Center, s)
}
+384
View File
@@ -0,0 +1,384 @@
package intercept
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
)
func formatRawRequest(req *intercept.PendingRequest) string {
r := req.Flow.Request
var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func formatRawResponse(resp *intercept.PendingResponse) string {
r := resp.Flow.Response
if r == nil {
return "(no response)"
}
var sb strings.Builder
proto := resp.Flow.Request.Proto
if proto == "" {
proto = "HTTP/1.1"
}
fmt.Fprintf(&sb, "%s %d %s\n", proto, r.StatusCode, http.StatusText(r.StatusCode))
keys := make([]string, 0, len(r.Header))
for k := range r.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
for _, v := range r.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
if len(r.Body) > 0 {
sb.Write(r.Body)
}
return sb.String()
}
func parseRawRequest(content string, req *intercept.PendingRequest) {
r := req.Flow.Request
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 1 {
r.Method = strings.TrimSpace(parts[0])
}
if len(parts) >= 2 {
if u, err := url.ParseRequestURI(strings.TrimSpace(parts[1])); err == nil {
r.URL.Path = u.Path
r.URL.RawQuery = u.RawQuery
}
}
if len(parts) >= 3 {
r.Proto = strings.TrimSpace(parts[2])
}
r.Header = make(http.Header)
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
}
i++
}
if i < len(lines) {
body := strings.Join(lines[i:], "\n")
body = strings.TrimRight(body, "\n")
if body != "" {
r.Body = []byte(body)
} else {
r.Body = nil
}
}
}
func parseRawResponse(content string, resp *intercept.PendingResponse) {
r := resp.Flow.Response
if r == nil {
return
}
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) >= 2 {
if code, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
r.StatusCode = code
}
}
r.Header = make(http.Header)
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
r.Header.Set(strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]))
}
i++
}
if i < len(lines) {
body := strings.Join(lines[i:], "\n")
body = strings.TrimRight(body, "\n")
if body != "" {
r.Body = []byte(body)
} else {
r.Body = nil
}
}
r.Header.Set("Content-Length", strconv.Itoa(len(r.Body)))
}
func (m *Model) currentLabel() string {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return ""
}
resp := m.responseQueue[m.responseCursor]
code := 0
if resp.Flow.Response != nil {
code = resp.Flow.Response.StatusCode
}
return fmt.Sprintf("%d %s %s", code, http.StatusText(code), resp.Flow.Request.URL.RequestURI())
}
if len(m.queue) == 0 {
return ""
}
req := m.queue[m.cursor]
return req.Flow.Request.Method + " " + req.Flow.Request.URL.RequestURI()
}
func (m *Model) removeFromQueue(index int) {
m.queue = append(m.queue[:index], m.queue[index+1:]...)
if m.cursor >= len(m.queue) && m.cursor > 0 {
m.cursor--
}
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) removeFromResponseQueue(index int) {
m.responseQueue = append(m.responseQueue[:index], m.responseQueue[index+1:]...)
if m.responseCursor >= len(m.responseQueue) && m.responseCursor > 0 {
m.responseCursor--
}
m.refreshResponseListViewport()
m.refreshBody()
}
func (m *Model) applyAndDecide(d intercept.Decision) {
if len(m.queue) == 0 {
return
}
req := m.queue[m.cursor]
if d == intercept.Forward {
if edited, ok := m.pendingEdits[req]; ok {
parseRawRequest(edited, req)
}
}
delete(m.pendingEdits, req)
m.broker.Decide(req, d)
m.removeFromQueue(m.cursor)
}
func (m *Model) applyAndDecideResponse(d intercept.Decision) {
if len(m.responseQueue) == 0 {
return
}
resp := m.responseQueue[m.responseCursor]
if d == intercept.Forward {
if edited, ok := m.pendingResponseEdits[resp]; ok {
parseRawResponse(edited, resp)
}
}
delete(m.pendingResponseEdits, resp)
m.broker.DecideResponse(resp, d)
m.removeFromResponseQueue(m.responseCursor)
}
func (m *Model) listHalfWidths() (leftW, rightW int) {
leftW = m.width / 2
rightW = m.width - leftW
return
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
bodyInner := m.width - 2
if bodyInner < 0 {
bodyInner = 0
}
bodyVH := style.PanelContentH(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
if listVH < 0 {
listVH = 0
}
if m.captureResponse {
leftW, rightW := m.listHalfWidths()
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
m.listViewport.SetWidth(leftInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
m.responseViewport.SetWidth(rightInner)
m.responseViewport.SetHeight(listVH)
m.responsePager.PerPage = listVH
if m.responsePager.PerPage < 1 {
m.responsePager.PerPage = 1
}
} else {
listInner := m.width - 2
if listInner < 0 {
listInner = 0
}
m.listViewport.SetWidth(listInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
}
m.refreshListViewport()
m.refreshResponseListViewport()
m.refreshBody()
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.queue))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshResponseListViewport() {
if m.responsePager.PerPage > 0 {
m.responsePager.Page = m.responseCursor / m.responsePager.PerPage
m.responsePager.SetTotalPages(len(m.responseQueue))
}
m.responseViewport.SetContent(m.renderResponseList())
}
// saveCurrentEdit must only be called when exiting edit mode.
func (m *Model) saveCurrentEdit() {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) > 0 {
m.pendingResponseEdits[m.responseQueue[m.responseCursor]] = m.textarea.Value()
}
} else {
if len(m.queue) > 0 {
m.pendingEdits[m.queue[m.cursor]] = m.textarea.Value()
}
}
}
const maxInlineEditBytes = 32 * 1024
func (m *Model) loadIntoTextarea() {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
m.textarea.SetValue(edited)
} else {
m.textarea.SetValue(formatRawResponse(resp))
}
} else {
if len(m.queue) == 0 {
return
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
m.textarea.SetValue(edited)
} else {
m.textarea.SetValue(formatRawRequest(req))
}
}
}
// refreshBody does not touch the textarea - it is only loaded when entering edit mode.
func (m *Model) refreshBody() {
var raw string
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
m.bodyViewport.SetContent("")
return
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
raw = edited
} else {
raw = formatRawResponse(resp)
}
} else {
if len(m.queue) == 0 {
m.bodyViewport.SetContent("")
return
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
raw = edited
} else {
raw = formatRawRequest(req)
}
}
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
func (m *Model) refreshBodyViewport() {
m.bodyViewport.SetContent(style.HighlightHTTP(m.textarea.Value()))
}
+34
View File
@@ -0,0 +1,34 @@
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 {
h := b.Help()
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(h.Key, icon+h.Desc))
}
func (interceptKeyMap) ShortHelp() []key.Binding {
ic := keys.Keys.Intercept
i := icons.I
return []key.Binding{
iconBinding(ic.Forward, i.Forward),
iconBinding(ic.Drop, i.Drop),
iconBinding(ic.Edit, i.Edit),
keys.Keys.Global.Help,
}
}
func (m interceptKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Intercept.Bindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+118
View File
@@ -0,0 +1,118 @@
package intercept
import (
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/style"
)
type panel int
const (
panelRequests panel = iota
panelResponses
)
type Model struct {
broker *intercept.Broker
queue []*intercept.PendingRequest
cursor int
captureResponse bool
focusedPanel panel
responseQueue []*intercept.PendingResponse
responseCursor int
editing bool
autoForward bool
pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string
listViewport viewport.Model
responseViewport viewport.Model
bodyViewport viewport.Model
textarea textarea.Model
pager paginator.Model
responsePager paginator.Model
help help.Model
width int
height int
}
func New(broker *intercept.Broker) Model {
cfg := config.Global
ta := style.NewTextarea(false)
ta.Blur()
lv := style.NewViewport()
rv := style.NewViewport()
bv := style.NewViewport()
p := style.NewPaginator()
rp := style.NewPaginator()
broker.SetCaptureResponse(cfg.Intercept.DefaultCaptureResponse)
return Model{
broker: broker,
autoForward: cfg.Intercept.DefaultAutoForward,
captureResponse: cfg.Intercept.DefaultCaptureResponse,
listViewport: lv,
responseViewport: rv,
bodyViewport: bv,
textarea: ta,
pager: p,
responsePager: rp,
help: newHelp(),
pendingEdits: make(map[*intercept.PendingRequest]string),
pendingResponseEdits: make(map[*intercept.PendingResponse]string),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) CurrentScheme() string {
if len(m.queue) == 0 {
return "https"
}
scheme := m.queue[m.cursor].Flow.Request.URL.Scheme
if scheme == "" {
return "https"
}
return scheme
}
func (m Model) CurrentRaw() string {
if m.captureResponse && m.focusedPanel == panelResponses {
if len(m.responseQueue) == 0 {
return ""
}
resp := m.responseQueue[m.responseCursor]
if edited, ok := m.pendingResponseEdits[resp]; ok {
return edited
}
return formatRawResponse(resp)
}
if len(m.queue) == 0 {
return ""
}
req := m.queue[m.cursor]
if edited, ok := m.pendingEdits[req]; ok {
return edited
}
return formatRawRequest(req)
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
+296
View File
@@ -0,0 +1,296 @@
package intercept
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/intercept"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/util"
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case intercept.RequestArrivedMsg:
if m.autoForward {
m.broker.Decide(msg.Req, intercept.Forward)
break
}
wasEmpty := len(m.queue) == 0
m.queue = append(m.queue, msg.Req)
m.refreshListViewport()
if wasEmpty && (!m.captureResponse || m.focusedPanel == panelRequests) {
m.refreshBody()
}
case intercept.ResponseArrivedMsg:
wasEmpty := len(m.responseQueue) == 0
m.responseQueue = append(m.responseQueue, msg.Resp)
m.refreshResponseListViewport()
if wasEmpty && m.captureResponse && m.focusedPanel == panelResponses {
m.refreshBody()
}
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" {
m.textarea.SetValue(msg.Content)
m.refreshBodyViewport()
}
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollLeft(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.bodyViewport.ScrollRight(6)
} else {
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.bodyViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.bodyViewport.ScrollRight(6)
}
}
case tea.KeyPressMsg:
if m.editing {
return m.updateEditMode(msg, &cmds)
}
return m.updateNormalMode(msg, &cmds)
}
return m, tea.Batch(cmds...)
}
func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Up):
if onResponses {
if m.responseCursor > 0 {
m.responseCursor--
m.refreshResponseListViewport()
m.refreshBody()
}
} else {
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Global.Down):
if onResponses {
if m.responseCursor < len(m.responseQueue)-1 {
m.responseCursor++
m.refreshResponseListViewport()
m.refreshBody()
}
} else {
if m.cursor < len(m.queue)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Global.CycleFocus):
if m.captureResponse {
if m.focusedPanel == panelRequests {
m.focusedPanel = panelResponses
} else {
m.focusedPanel = panelRequests
}
m.refreshBody()
}
case key.Matches(msg, keys.Keys.Global.ScrollUp):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
case key.Matches(msg, keys.Keys.Global.ScrollDown):
step := m.bodyViewport.Height() / 2
if step < 1 {
step = 1
}
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
case key.Matches(msg, keys.Keys.Global.Left):
m.bodyViewport.ScrollLeft(6)
case key.Matches(msg, keys.Keys.Global.Right):
m.bodyViewport.ScrollRight(6)
case key.Matches(msg, keys.Keys.Global.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.refreshBody()
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.refreshBody()
}
}
case key.Matches(msg, keys.Keys.Intercept.AutoForward):
m.autoForward = !m.autoForward
if m.autoForward {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward)
}
}
case key.Matches(msg, keys.Keys.Intercept.CaptureResponse):
m.captureResponse = !m.captureResponse
m.broker.SetCaptureResponse(m.captureResponse)
if !m.captureResponse {
for len(m.responseQueue) > 0 {
m.broker.DecideResponse(m.responseQueue[0], intercept.Forward)
m.responseQueue = m.responseQueue[1:]
}
m.responseCursor = 0
m.focusedPanel = panelRequests
}
m.recalcSizes()
case key.Matches(msg, keys.Keys.Global.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
case key.Matches(msg, keys.Keys.Intercept.Forward):
if onResponses {
m.applyAndDecideResponse(intercept.Forward)
} else {
m.applyAndDecide(intercept.Forward)
}
case key.Matches(msg, keys.Keys.Intercept.ForwardAll):
if onResponses {
for len(m.responseQueue) > 0 {
m.applyAndDecideResponse(intercept.Forward)
}
} else {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward)
}
}
case key.Matches(msg, keys.Keys.Intercept.Drop):
if onResponses {
m.applyAndDecideResponse(intercept.Drop)
} else {
m.applyAndDecide(intercept.Drop)
}
case key.Matches(msg, keys.Keys.Intercept.DropAll):
if onResponses {
for len(m.responseQueue) > 0 {
m.applyAndDecideResponse(intercept.Drop)
}
} else {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Drop)
}
}
case key.Matches(msg, keys.Keys.Intercept.Edit):
hasItem := (!onResponses && len(m.queue) > 0) || (onResponses && len(m.responseQueue) > 0)
if hasItem {
raw := m.CurrentRaw()
if len(raw) > maxInlineEditBytes {
return m, util.OpenExternalEditor(raw)
}
m.loadIntoTextarea()
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, keys.Keys.Intercept.EditExternal):
if !onResponses && len(m.queue) > 0 {
return m, util.OpenExternalEditor(formatRawRequest(m.queue[m.cursor]))
}
if onResponses && len(m.responseQueue) > 0 {
return m, util.OpenExternalEditor(formatRawResponse(m.responseQueue[m.responseCursor]))
}
case key.Matches(msg, keys.Keys.Global.SendToReplay):
if !onResponses && len(m.queue) > 0 {
req := m.queue[m.cursor]
raw := m.CurrentRaw()
scheme := req.Flow.Request.URL.Scheme
if scheme == "" {
scheme = "https"
}
return m, func() tea.Msg {
return replayUI.SendToReplayMsg{
Scheme: scheme,
Host: req.Flow.Request.URL.Host,
RequestRaw: raw,
}
}
}
case key.Matches(msg, keys.Keys.Global.SendToDiff):
raw := m.CurrentRaw()
if raw != "" {
label := m.currentLabel()
return m, func() tea.Msg {
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
}
}
}
return m, tea.Batch(*cmds...)
}
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit()
m.editing = false
m.textarea.Blur()
m.refreshBodyViewport()
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(formatRawResponse(m.responseQueue[m.responseCursor]))
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(formatRawRequest(m.queue[m.cursor]))
}
}
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
*cmds = append(*cmds, cmd)
}
return m, tea.Batch(*cmds...)
}
+220
View File
@@ -0,0 +1,220 @@
package intercept
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
var listRow string
if m.captureResponse {
leftW, rightW := m.listHalfWidths()
listRow = lipgloss.JoinHorizontal(lipgloss.Top,
m.renderListPanel(leftW, listH),
m.renderResponseListPanel(rightW, listH),
)
} else {
listRow = m.renderListPanel(m.width, listH)
}
content := lipgloss.JoinVertical(lipgloss.Left,
listRow,
m.renderBodyPanel(bodyH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
focused := !m.editing && (!m.captureResponse || m.focusedPanel == panelRequests)
border := s.Panel
if focused {
border = s.PanelFocused
}
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
title := icons.I.Request + "Requests"
if m.autoForward {
title += " [auto forward]"
}
return style.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
if focused {
border = s.PanelFocused
}
dots := 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)
}
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()
}
border := s.Panel
if m.editing {
border = s.PanelFocused
}
title := icons.I.Detail + "Details"
return style.RenderWithTitle(border, title, body, m.width, h)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(interceptKeyMap{width: m.width}))
}
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(" (。◕‿‿◕。)\nwaiting for a request"))
}
s := style.S
start, end := m.pager.GetSliceBounds(len(m.queue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, req := range m.queue[start:end] {
globalIdx := start + i
r := req.Flow.Request
path := r.URL.Path
if path == "" {
path = "/"
}
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 2
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
var line 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.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),
)
}
sb.WriteString(line + "\n")
}
return sb.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(" (҂◡_◡)\nno response yet"))
}
s := style.S
start, end := m.responsePager.GetSliceBounds(len(m.responseQueue))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, resp := range m.responseQueue[start:end] {
globalIdx := start + i
f := resp.Flow
path := f.Request.URL.Path
if path == "" {
path = "/"
}
code := 0
if f.Response != nil {
code = f.Response.StatusCode
}
statusStr := fmt.Sprintf("%d", code)
selected := globalIdx == m.responseCursor
selBg := s.Selection
statusSt := style.StatusStyle(code, 7)
w := m.responseViewport.Width()
const fixedW = 2 + 7 + 2
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(selBg)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(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),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
statusSt.Render(statusStr),
s.Faint.Render(" "),
s.Bold.Render(f.Request.URL.Host),
s.Faint.Render(path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
+180
View File
@@ -0,0 +1,180 @@
package plugins
import (
"os"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
manager *plugins.Manager
items []plugins.Info
cursor int
editing bool
filter string
filtered []plugins.Info
listViewport viewport.Model
textarea textarea.Model
filterInput textinput.Model
filtering bool
pager paginator.Model
help help.Model
width int
height int
}
func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false)
ta.Placeholder = "plugin configuration..."
ta.Blur()
fi := textinput.New()
fi.Prompt = ""
return Model{
manager: mgr,
listViewport: style.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing || m.filtering }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
listH, detailH := style.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
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
m.filterInput.SetWidth(inner - 2)
m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6))
m.refreshListViewport()
}
// Refresh reloads the plugin list from the manager.
func (m *Model) Refresh() {
if m.manager == nil {
return
}
pl := m.manager.GetPlugins()
m.items = make([]plugins.Info, len(pl))
for i, p := range pl {
m.items[i] = p.Info()
}
m.applyFilter()
}
func (m *Model) applyFilter() {
if m.filter == "" {
m.filtered = m.items
} else {
f := strings.ToLower(m.filter)
filtered := make([]plugins.Info, 0, len(m.items))
for _, p := range m.items {
if strings.Contains(strings.ToLower(p.Name), f) {
filtered = append(filtered, p)
}
}
m.filtered = filtered
}
m.pager.SetTotalPages(len(m.filtered))
if m.cursor >= len(m.filtered) {
m.cursor = max(0, len(m.filtered)-1)
}
m.refreshListViewport()
m.syncTextarea()
}
func (m *Model) selected() (plugins.Info, bool) {
if len(m.filtered) == 0 {
return plugins.Info{}, false
}
return m.filtered[m.cursor], true
}
func (m *Model) syncTextarea() {
if m.editing {
return
}
info, ok := m.selected()
if !ok {
m.textarea.SetValue("")
return
}
m.textarea.SetValue(info.ConfigText)
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.filtered))
}
m.listViewport.SetContent(m.renderList())
}
func shortenPath(p string) string {
home := os.Getenv("HOME")
if home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins
g := keys.Keys.Global
if k.editing {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc}
}
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
}
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+130
View File
@@ -0,0 +1,130 @@
package plugins
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
// PluginsChangedMsg is sent when the plugin list should be refreshed.
type PluginsChangedMsg struct{}
// RefreshCmd returns a command that triggers a list refresh.
func RefreshCmd() tea.Cmd {
return func() tea.Msg { return PluginsChangedMsg{} }
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case PluginsChangedMsg:
m.Refresh()
return m, nil
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
pk := keys.Keys.Plugins
g := keys.Keys.Global
// Filtering mode: esc clears+closes, enter just closes, rest goes to filterInput.
if m.filtering {
switch {
case key.Matches(msg, g.Escape):
m.filtering = false
m.filter = ""
m.filterInput.SetValue("")
m.filterInput.Blur()
m.applyFilter()
m.recalcSizes()
case msg.String() == "enter":
m.filtering = false
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.filter = m.filterInput.Value()
m.applyFilter()
return m, cmd
}
return m, nil
}
// Editing mode: only esc exits, everything else goes to textarea.
if m.editing {
if key.Matches(msg, g.Escape) {
m.editing = false
m.textarea.Blur()
if info, ok := m.selected(); ok && m.manager != nil {
val := m.textarea.Value()
m.manager.SaveConfig(info.Name, val)
// Update cached info.
m.filtered[m.cursor].ConfigText = val
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].ConfigText = val
break
}
}
}
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, g.Escape):
if m.filter != "" {
m.filter = ""
m.filterInput.SetValue("")
m.applyFilter()
}
case key.Matches(msg, pk.Filter):
m.filtering = true
m.filterInput.Focus()
m.recalcSizes()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 {
m.cursor++
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, pk.Toggle):
if info, ok := m.selected(); ok && m.manager != nil {
m.manager.TogglePlugin(info.Name)
m.filtered[m.cursor].Enabled = !info.Enabled
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].Enabled = !info.Enabled
break
}
}
m.refreshListViewport()
}
case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok {
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
+150
View File
@@ -0,0 +1,150 @@
package plugins
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil {
return tea.NewView(style.S.Faint.Render("\nno plugins loaded"))
}
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderDetailPanel(detailH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := 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.Plugin+"Plugins", inner, w, h)
}
func (m *Model) renderDetailPanel(h int) string {
s := style.S
info, ok := m.selected()
if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h)
}
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success)
}
status := "disabled"
if info.Enabled {
status = "enabled"
}
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):"))
}
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()),
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()),
)
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h)
}
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())
}
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"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})))
}
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
}
func (m *Model) renderList() string {
s := style.S
if len(m.filtered) == 0 {
msg := " (ง •̀_•́)ง\nno plugins"
if m.filter != "" {
msg = " = _ =\nno results"
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
)
}
start, end := m.pager.GetSliceBounds(len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
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)
enabledStr := "off"
if p.Enabled {
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
enabledStr = "on "
}
w := m.listViewport.Width()
const fixedW = 2 + 3 + 1
nameW := w - fixedW
if nameW < 0 {
nameW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(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.Width(1).Render(""),
bg.Bold(true).Width(nameW).Render(p.Name),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
enabledSt.Width(3).Render(enabledStr),
" ",
s.Bold.Render(p.Name),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
+175
View File
@@ -0,0 +1,175 @@
package replay
import (
"fmt"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
type SendToReplayMsg struct {
Scheme string
Host string
RequestRaw string
}
type Entry struct {
DBID int64
Scheme string
Host string
Path string
Method string
OriginalRaw string
RequestRaw string // current (possibly edited) request
ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet
Sending bool
Err error
}
type Model struct {
entries []Entry
cursor int
editing bool
database *db.DB
listViewport viewport.Model
requestViewport viewport.Model
responseViewport viewport.Model
textarea textarea.Model
pager paginator.Model
help help.Model
width int
height int
}
func New() Model {
ta := style.NewTextarea(false)
ta.Blur()
return Model{
listViewport: style.NewViewport(),
requestViewport: style.NewViewport(),
responseViewport: style.NewViewport(),
textarea: ta,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m *Model) SetDB(d *db.DB) {
m.database = d
if d == nil {
return
}
entries, err := d.ListReplayEntries()
if err != nil {
return
}
for _, dbe := range entries {
m.entries = append(m.entries, entryFromDB(dbe))
}
m.pager.SetTotalPages(len(m.entries))
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
}
m.refreshListViewport()
m.refreshBody()
}
func entryFromDB(dbe db.ReplayEntry) Entry {
var err error
if dbe.ErrorMsg != "" {
err = fmt.Errorf("%s", dbe.ErrorMsg)
}
return Entry{
DBID: dbe.ID,
Scheme: dbe.Scheme,
Host: dbe.Host,
Path: dbe.Path,
Method: dbe.Method,
OriginalRaw: dbe.OriginalRaw,
RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode,
Err: err,
}
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
listH, bodyH := style.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
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(listInner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
leftW, rightW := m.bodyHalfWidths()
leftInner := leftW - 2
rightInner := rightW - 2
if leftInner < 0 {
leftInner = 0
}
if rightInner < 0 {
rightInner = 0
}
bodyVH := style.PanelContentH(bodyH)
m.requestViewport.SetWidth(leftInner)
m.requestViewport.SetHeight(bodyVH)
m.responseViewport.SetWidth(rightInner)
m.responseViewport.SetHeight(bodyVH)
m.textarea.SetWidth(leftInner)
m.textarea.SetHeight(bodyVH)
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) bodyHalfWidths() (left, right int) {
left = m.width / 2
right = m.width - left
return
}
type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, r.Send, r.Edit, g.Help}
}
func (m replayKeyMap) FullHelp() [][]key.Binding {
all := append(keys.Keys.Replay.Bindings(), keys.Keys.Global.Bindings()...)
return keys.ChunkByWidth(all, m.width)
}
+413
View File
@@ -0,0 +1,413 @@
package replay
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"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 sentMsg struct {
index int
responseRaw string
statusCode int
err error
}
func sendCmd(entry Entry, index int) tea.Cmd {
return func() tea.Msg {
raw, code, err := doSend(entry)
return sentMsg{index: index, responseRaw: raw, statusCode: code, err: err}
}
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case SendToReplayMsg:
entry := entryFromMsg(msg)
if m.database != nil {
id, err := m.database.InsertReplayEntry(entryToDB(entry))
if err == nil {
entry.DBID = id
}
}
m.entries = append(m.entries, entry)
m.cursor = len(m.entries) - 1
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) {
e := &m.entries[msg.index]
e.Sending = false
e.StatusCode = msg.statusCode
e.ResponseRaw = msg.responseRaw
if msg.err != nil {
e.Err = msg.err
e.ResponseRaw = "Error: " + msg.err.Error()
}
if m.database != nil && e.DBID != 0 {
m.database.UpdateReplayEntry(entryToDB(*e))
}
}
m.refreshListViewport()
m.refreshBody()
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = msg.Content
m.refreshBody()
}
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
}
case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
case tea.MouseWheelRight:
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
}
}
case tea.KeyPressMsg:
if m.editing {
return m.updateEditMode(msg)
}
return m.updateNormalMode(msg)
}
return m, nil
}
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global
r := keys.Keys.Replay
switch {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
m.entries[m.cursor].Sending = true
m.entries[m.cursor].ResponseRaw = ""
m.entries[m.cursor].Err = nil
m.refreshListViewport()
m.refreshBody()
return m, sendCmd(m.entries[m.cursor], m.cursor)
}
case key.Matches(msg, r.Edit):
if len(m.entries) > 0 {
m.textarea.SetValue(m.entries[m.cursor].RequestRaw)
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, r.EditExt):
if len(m.entries) > 0 {
return m, util.OpenExternalEditor(m.entries[m.cursor].RequestRaw)
}
case key.Matches(msg, r.UndoEdits):
if len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = m.entries[m.cursor].OriginalRaw
m.refreshBody()
}
case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
case key.Matches(msg, g.Right):
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
case key.Matches(msg, r.Delete):
if len(m.entries) > 0 {
e := m.entries[m.cursor]
if m.database != nil && e.DBID != 0 {
m.database.DeleteReplayEntry(e.DBID)
}
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 {
m.cursor--
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
case key.Matches(msg, r.DeleteAll):
if m.database != nil {
m.database.DeleteAllReplayEntries()
}
m.entries = nil
m.cursor = 0
m.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
return m, nil
}
func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
if len(m.entries) > 0 {
m.entries[m.cursor].RequestRaw = m.textarea.Value()
}
m.editing = false
m.textarea.Blur()
m.refreshBody()
default:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
return m, nil
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.entries))
}
m.listViewport.SetContent(m.renderList())
}
func (m *Model) refreshBody() {
if len(m.entries) == 0 {
m.requestViewport.SetContent("")
m.responseViewport.SetContent("")
return
}
e := m.entries[m.cursor]
m.requestViewport.SetContent(style.HighlightHTTP(e.RequestRaw))
m.requestViewport.SetYOffset(0)
m.requestViewport.SetXOffset(0)
if e.Sending {
m.responseViewport.SetContent(style.HighlightHTTP("Sending..."))
} else if e.ResponseRaw != "" {
m.responseViewport.SetContent(style.HighlightHTTP(e.ResponseRaw))
} else {
m.responseViewport.SetContent("")
}
m.responseViewport.SetYOffset(0)
m.responseViewport.SetXOffset(0)
}
func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
lines := strings.Split(strings.ReplaceAll(entry.RequestRaw, "\r\n", "\n"), "\n")
if len(lines) == 0 {
return "", 0, fmt.Errorf("empty request")
}
parts := strings.SplitN(lines[0], " ", 3)
if len(parts) < 2 {
return "", 0, fmt.Errorf("invalid request line")
}
method := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])
headers := make(http.Header)
host := entry.Host
i := 1
for i < len(lines) {
line := strings.TrimRight(lines[i], "\r")
if line == "" {
i++
break
}
if kv := strings.SplitN(line, ": ", 2); len(kv) == 2 {
k := strings.TrimSpace(kv[0])
v := strings.TrimSpace(kv[1])
if strings.ToLower(k) == "host" {
host = v
} else {
headers.Add(k, v)
}
}
i++
}
var bodyBytes []byte
if i < len(lines) {
b := strings.Join(lines[i:], "\n")
b = strings.TrimRight(b, "\n")
bodyBytes = []byte(b)
}
scheme := entry.Scheme
if scheme == "" {
scheme = "https"
}
urlStr := scheme + "://" + host + path
var bodyReader io.Reader
if len(bodyBytes) > 0 {
bodyReader = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return "", 0, err
}
req.Header = headers
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
sortedKeys := make([]string, 0, len(resp.Header))
for k := range resp.Header {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
for _, k := range sortedKeys {
for _, v := range resp.Header[k] {
fmt.Fprintf(&sb, "%s: %s\n", k, v)
}
}
sb.WriteString("\n")
sb.Write(respBody)
return sb.String(), resp.StatusCode, nil
}
func entryToDB(e Entry) db.ReplayEntry {
errMsg := ""
if e.Err != nil {
errMsg = e.Err.Error()
}
return db.ReplayEntry{
ID: e.DBID,
Timestamp: time.Now(),
Scheme: e.Scheme,
Host: e.Host,
Path: e.Path,
Method: e.Method,
OriginalRaw: e.OriginalRaw,
RequestRaw: e.RequestRaw,
ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode,
ErrorMsg: errMsg,
}
}
func entryFromMsg(msg SendToReplayMsg) Entry {
method, host, path := parseFirstLine(msg.RequestRaw, msg.Host)
scheme := msg.Scheme
if scheme == "" {
scheme = util.InferScheme(host)
}
return Entry{
Scheme: scheme,
Host: host,
Path: path,
Method: method,
OriginalRaw: msg.RequestRaw,
RequestRaw: msg.RequestRaw,
}
}
func parseFirstLine(raw, fallbackHost string) (method, host, path string) {
host = fallbackHost
path = "/"
lines := strings.SplitN(raw, "\n", 2)
if len(lines) == 0 {
return
}
parts := strings.Fields(lines[0])
if len(parts) >= 1 {
method = parts[0]
}
if len(parts) >= 2 {
path = parts[1]
}
if len(lines) > 1 {
for _, line := range strings.Split(lines[1], "\n") {
if strings.HasPrefix(strings.ToLower(line), "host:") {
host = strings.TrimSpace(line[5:])
break
}
}
}
return
}
+137
View File
@@ -0,0 +1,137 @@
package replay
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("Loading...")
}
listH, bodyH := style.SplitH(m.height, m.renderStatusBar(), 0.35)
leftW, rightW := m.bodyHalfWidths()
bodyRow := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderRequestPanel(leftW, bodyH),
m.renderResponsePanel(rightW, bodyH),
)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
bodyRow,
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := 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.Replay+"Replay", inner, w, h)
}
func (m *Model) renderRequestPanel(w, h int) string {
s := style.S
var body string
border := s.Panel
if m.editing {
body = m.textarea.View()
border = s.PanelFocused
} else {
body = m.requestViewport.View()
}
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
}
func (m *Model) renderResponsePanel(w, h int) string {
s := style.S
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h)
}
func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width}))
}
func (m *Model) renderList() string {
if len(m.entries) == 0 {
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
style.S.Faint.Render(" (╥﹏╥)\nsend 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
}
var sb strings.Builder
for i, e := range m.entries[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
selBg := s.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
statusStr, statusSt := entryStatus(e)
var line 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.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
s.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
" ",
s.Bold.Render(e.Host),
s.Faint.Render(e.Path),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}
func entryStatus(e Entry) (string, lipgloss.Style) {
base := lipgloss.NewStyle().Bold(true).Width(3)
switch {
case e.Sending:
return "···", base.Foreground(style.S.Subtle)
case e.Err != nil:
return "ERR", base.Foreground(style.S.Error)
case e.StatusCode == 0:
return "---", base.Foreground(style.S.Subtle)
}
return fmt.Sprintf("%3d", e.StatusCode), style.StatusStyle(e.StatusCode, 3)
}
+150
View File
@@ -0,0 +1,150 @@
package scope
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
const (
fieldNone = -1
fieldWhitelist = 0
fieldBlacklist = 1
)
const (
minTaH = 3
maxTaH = 12
fixedH = 8 // (blank + label + desc + blank) x2
)
type ScopeChangedMsg struct {
Whitelist []string
Blacklist []string
}
type Model struct {
focusIdx int
wlTextarea textarea.Model
blTextarea textarea.Model
innerH int
width int
height int
help help.Model
}
func New(name, path string) Model {
wl := style.NewTextarea(true)
wl.Placeholder = "one pattern per line..."
bl := style.NewTextarea(true)
bl.Placeholder = "one pattern per line..."
bl.Blur()
return Model{
focusIdx: fieldNone,
wlTextarea: wl,
blTextarea: bl,
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetScope(whitelist, blacklist []string) {
m.wlTextarea.SetValue(strings.Join(whitelist, "\n"))
m.blTextarea.SetValue(strings.Join(blacklist, "\n"))
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.syncLayout()
}
func (m *Model) syncLayout() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
m.innerH = max(1, style.PanelContentH(panelH))
taH := (m.innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
// width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor)
taW := max(1, m.width-6)
m.wlTextarea.SetWidth(taW)
m.wlTextarea.SetHeight(taH)
m.blTextarea.SetWidth(taW)
m.blTextarea.SetHeight(taH)
}
func (m Model) IsEditing() bool {
return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist
}
func (m *Model) scopeChangedCmd() tea.Cmd {
wl := parseLines(m.wlTextarea.Value())
bl := parseLines(m.blTextarea.Value())
return func() tea.Msg {
return ScopeChangedMsg{Whitelist: wl, Blacklist: bl}
}
}
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
out = append(out, t)
}
}
return out
}
func (m Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(
m.help.View(formKeyMap{focusIdx: m.focusIdx}),
)
}
type formKeyMap struct {
focusIdx int
}
func (k formKeyMap) ShortHelp() []key.Binding {
cycle := keys.Keys.Global.CycleFocus
hlp := keys.Keys.Global.Help
switch k.focusIdx {
case fieldWhitelist, fieldBlacklist:
esc := keys.Keys.Global.Escape
escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus"))
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")),
escBinding,
cycle,
}
}
return []key.Binding{cycle, hlp}
}
func (k formKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+70
View File
@@ -0,0 +1,70 @@
package scope
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
kp, isKey := msg.(tea.KeyPressMsg)
if !isKey {
return m, nil
}
if key.Matches(kp, keys.Keys.Global.CycleFocus) {
return m.cycleFocus()
}
if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
switch m.focusIdx {
case fieldWhitelist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.wlTextarea, cmd = m.wlTextarea.Update(kp)
return m, cmd
case fieldBlacklist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.blTextarea, cmd = m.blTextarea.Update(kp)
return m, cmd
}
return m, nil
}
func (m Model) blurAll() (tea.Model, tea.Cmd) {
m.wlTextarea.Blur()
m.blTextarea.Blur()
m.focusIdx = fieldNone
m.syncLayout()
return m, m.scopeChangedCmd()
}
func (m Model) cycleFocus() (tea.Model, tea.Cmd) {
scopeCmd := m.scopeChangedCmd()
var focusCmd tea.Cmd
switch m.focusIdx {
case fieldNone, fieldBlacklist:
m.blTextarea.Blur()
m.focusIdx = fieldWhitelist
focusCmd = m.wlTextarea.Focus()
case fieldWhitelist:
m.wlTextarea.Blur()
m.focusIdx = fieldBlacklist
focusCmd = m.blTextarea.Focus()
}
m.syncLayout()
return m, tea.Batch(focusCmd, scopeCmd)
}
+84
View File
@@ -0,0 +1,84 @@
package scope
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("")
}
s := style.S
statusBar := m.renderStatusBar()
statusH := strings.Count(statusBar, "\n") + 1
panelH := m.height - statusH
innerH := max(1, style.PanelContentH(panelH))
taH := (innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
var lines []string
add := func(l string) { lines = append(lines, l) }
add("")
add(fieldLabel("Whitelist", m.focusIdx == fieldWhitelist))
add(" " + s.Faint.Render("If non-empty, only matching requests are intercepted."))
add("")
wlContentLines := strings.Count(m.wlTextarea.Value(), "\n") + 1
for _, l := range taLines(m.wlTextarea.View(), taH, wlContentLines) {
add(" " + l)
}
add("")
add(fieldLabel("Blacklist", m.focusIdx == fieldBlacklist))
add(" " + s.Faint.Render("Matching requests are always excluded from history."))
add("")
blContentLines := strings.Count(m.blTextarea.Value(), "\n") + 1
for _, l := range taLines(m.blTextarea.View(), taH, blContentLines) {
add(" " + l)
}
for len(lines) < innerH {
lines = append(lines, "")
}
content := strings.Join(lines[:innerH], "\n")
panel := style.RenderWithTitle(s.PanelFocused, icons.I.Scope+"Scopes", content, m.width, panelH)
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, panel, statusBar))
}
func fieldLabel(name string, focused bool) string {
s := style.S
c := s.MutedFg
if focused {
c = s.Primary
}
return " " + lipgloss.NewStyle().Foreground(c).Bold(focused).Render(name)
}
func taLines(view string, h int, contentLines int) []string {
raw := strings.Split(strings.TrimRight(view, "\n"), "\n")
tilde := style.S.Faint.Render("~")
for len(raw) < h {
raw = append(raw, tilde)
}
if len(raw) > h {
raw = raw[:h]
}
for i := contentLines; i < len(raw); i++ {
raw[i] = tilde
}
return raw
}
+38
View File
@@ -0,0 +1,38 @@
package util
import (
"os"
"os/exec"
tea "charm.land/bubbletea/v2"
)
type EditorFinishedMsg struct {
Content string
Err error
}
func OpenExternalEditor(content string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
f, err := os.CreateTemp("", "spilltea-*.http")
if err != nil {
return nil
}
tmpPath := f.Name()
_, _ = f.WriteString(content)
f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath)
if err != nil {
return EditorFinishedMsg{Err: err}
}
data, readErr := os.ReadFile(tmpPath)
if readErr != nil {
return EditorFinishedMsg{Err: readErr}
}
return EditorFinishedMsg{Content: string(data)}
})
}
+18
View File
@@ -0,0 +1,18 @@
package util
import "strings"
func Truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
// InferScheme returns "http" for port 80, "https" otherwise.
func InferScheme(host string) string {
if strings.HasSuffix(host, ":80") {
return "http"
}
return "https"
}