mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 01:32:33 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "/"
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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" +
|
||||
" _\\_______/_ ",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user