5 Commits

Author SHA1 Message Date
Hadi dbea0ab0f2 Add cli flags
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:54:36 +02:00
Hadi a6bd5c1071 Rename auto-forward -> toggle-intercept
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:25:10 +02:00
Hadi 7879720d07 Remove scope page
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 22:01:29 +02:00
Hadi 329216c082 Add issue templates
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 19:38:47 +02:00
Hadi 1c66837504 Edit main path
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-12 19:16:31 +02:00
24 changed files with 110 additions and 506 deletions
+28
View File
@@ -0,0 +1,28 @@
---
name: "🐞 Bug Report"
about: Report a reproducible error
title: "[BUG] "
labels: bug
---
**Describe the bug**
A clear and concise description of the issue.
**To Reproduce**
1. Go to '...'
2. Click on '....'
3. See error
**Expected Behavior**
What should have happened.
**Environment**
- OS:
- Version (`spilltea -v`):
**Additional Context**
Add any logs or screenshots here.
+22
View File
@@ -0,0 +1,22 @@
---
name: "🚀 Feature Request"
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
---
**Is your feature request related to a problem?**
A description of what the problem is (e.g. I'm always frustrated when...).
**Describe the solution**
A clear description of what you want to happen.
**Describe alternatives**
Any alternative solutions or features you've considered.
**Additional context**
Add any other context or mockups here.
-19
View File
@@ -1,19 +0,0 @@
## Scopes
Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`).
- **Whitelist**: if non-empty, only matching requests are intercepted.
- **Blacklist**: matching requests are always ignored, even if whitelisted.
When both lists are set, a request must pass the whitelist _and_ not be in the blacklist.
### Examples
| Pattern | Matches |
| -------------------------- | ----------------------------------- |
| `example\.com` | any request to `example.com` |
| `^api\.example\.com` | only the `api` subdomain |
| `example\.com/api/v2` | a specific path prefix |
| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) |
| `googleapis\.com` | all Google API traffic |
| `/graphql$` | any host with a `/graphql` endpoint |
+3
View File
@@ -6,6 +6,9 @@ before:
builds:
- binary: spilltea
main: ./cmd/spilltea
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
+10 -1
View File
@@ -25,7 +25,6 @@ It is intentionally minimal. No Electron, no browser, no bloat. Just a fast, key
- **Intercept**: Pause requests and responses in-flight. Inspect and modify them (even with your favorite editor) before forwarding.
- **HTTP History**: Every request that passes through the proxy is stored. Browse, search and filter your full session history.
- **Replay**: Pick any request from the history, modify it if needed, and send it again. Useful for manual testing and quick iteration
- **Scopes**: Keep your history clean by white/blacklisting domains or specific paths.
- **HTTPS Support** (using go-mitmproxy under the hood)
- Built-in Integrations:
- **FFuf Export**: Generate a ffuf command or configuration directly from a request to start fuzzing instantly.
@@ -52,6 +51,16 @@ For a full reference and examples, see the [plugin documentation](./.github/docs
Spilltea is fully configured via a YAML file at `~/.config/spilltea/config.yaml`.
Check the default configuration with all the options [here](./internal/config/default_config.yaml)
## CLI Flags
| Flag | Short | Description |
| ---- | ----- | ----------- |
| `--config` | `-c` | Path to config file (default: `~/.config/spilltea/config.yaml`) |
| `--host` | | Proxy host, overrides config |
| `--port` | `-p` | Proxy port, overrides config |
| `--project` | `-P` | Project name to open directly, or `tmp` for a temporary session |
| `--version` | `-v` | Print version and exit |
## Deployment
spilltea runs **locally** on the machine used for pentesting or CTF. There is no separate server component.
+2 -1
View File
@@ -33,8 +33,9 @@ type Config struct {
} `mapstructure:"tui"`
Intercept struct {
DefaultAutoForward bool `mapstructure:"default_auto_forward"`
DefaultInterceptEnabled bool `mapstructure:"default_intercept_enabled"`
DefaultCaptureResponse bool `mapstructure:"default_capture_response"`
AutoForwardRegex []string `mapstructure:"auto_forward_regex"`
} `mapstructure:"intercept"`
Replay struct {
+4 -2
View File
@@ -6,8 +6,10 @@ app:
plugins_dir: ~/.config/spilltea/plugins
intercept:
default_auto_forward: false
default_intercept_enabled: true
default_capture_response: false
auto_forward_regex:
- '\.(js|css|png|gif|ico|woff2?|ttf|svg)(\?.*)?$'
replay:
switch_to_page_on_send: false
@@ -59,7 +61,7 @@ keybindings:
forward_all: "F"
drop: "d"
drop_all: "D"
auto_forward: "a"
toggle_intercept: "a"
capture_response: "r"
undo_edits: "ctrl+z"
edit: "e,enter"
+1 -1
View File
@@ -22,7 +22,7 @@ type InterceptKeys struct {
ForwardAll string `mapstructure:"forward_all"`
Drop string `mapstructure:"drop"`
DropAll string `mapstructure:"drop_all"`
AutoForward string `mapstructure:"auto_forward"`
ToggleIntercept string `mapstructure:"toggle_intercept"`
CaptureResponse string `mapstructure:"capture_response"`
UndoEdits string `mapstructure:"undo_edits"`
Edit string `mapstructure:"edit"`
+1 -9
View File
@@ -35,12 +35,7 @@ func (d *DB) migrate() error {
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 (
CREATE TABLE IF NOT EXISTS replay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL,
scheme TEXT NOT NULL,
@@ -69,9 +64,6 @@ func (d *DB) migrate() error {
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
}
-45
View File
@@ -1,45 +0,0 @@
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()
}
+24 -56
View File
@@ -41,9 +41,8 @@ type Broker struct {
droppedFlows sync.Map // *proxy.Flow → struct{}
outOfScope sync.Map // *proxy.Flow → struct{}
scopeMu sync.RWMutex
whitelist []*regexp.Regexp
blacklist []*regexp.Regexp
autoFwdMu sync.RWMutex
autoFwdRegexes []*regexp.Regexp
onNewEntry func(db.Entry)
}
@@ -52,76 +51,44 @@ 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{
b := &Broker{
Incoming: make(chan *PendingRequest, 64),
IncomingResponse: make(chan *PendingResponse, 64),
}
b.SetAutoForwardRegex(config.Global.Intercept.AutoForwardRegex)
return b
}
func (b *Broker) SetCaptureResponse(v bool) {
b.captureResponse.Store(v)
}
// SetScope compiles and stores whitelist/blacklist regex patterns.
// SetAutoForwardRegex compiles and stores patterns for requests that should
// be forwarded automatically without interception or history logging.
// 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))
func (b *Broker) SetAutoForwardRegex(patterns []string) {
compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
if r, err := regexp.Compile(p); err == nil {
out = append(out, r)
compiled = append(compiled, r)
}
}
return out
b.autoFwdMu.Lock()
b.autoFwdRegexes = compiled
b.autoFwdMu.Unlock()
}
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 {
func (b *Broker) isAutoForwarded(target string) bool {
b.autoFwdMu.RLock()
regexes := b.autoFwdRegexes
b.autoFwdMu.RUnlock()
for _, r := range regexes {
if r.MatchString(target) {
matched = true
break
}
}
if !matched {
return false
}
}
for _, r := range bl {
if r.MatchString(target) {
return false
}
}
return true
}
}
return false
}
func (b *Broker) SetDB(d *db.DB) {
@@ -132,7 +99,8 @@ func (b *Broker) SetDB(d *db.DB) {
// 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) {
target := f.Request.URL.Host + f.Request.URL.Path
if b.isAutoForwarded(target) {
b.outOfScope.Store(f, struct{}{})
return Forward
}
@@ -168,7 +136,7 @@ func (b *Broker) HoldResponse(f *proxy.Flow) 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.
// Flows that were dropped or auto-forwarded are silently skipped.
func (b *Broker) SaveEntry(f *proxy.Flow) {
b.dbMu.RLock()
d := b.database
+3 -3
View File
@@ -10,7 +10,7 @@ type InterceptKeyMap struct {
ForwardAll key.Binding
Drop key.Binding
DropAll key.Binding
AutoForward key.Binding
ToggleIntercept key.Binding
CaptureResponse key.Binding
UndoEdits key.Binding
Edit key.Binding
@@ -23,7 +23,7 @@ func newInterceptKeyMap(cfg config.InterceptKeys) InterceptKeyMap {
ForwardAll: binding(cfg.ForwardAll, "forward all"),
Drop: binding(cfg.Drop, "drop"),
DropAll: binding(cfg.DropAll, "drop all"),
AutoForward: binding(cfg.AutoForward, "auto forward"),
ToggleIntercept: binding(cfg.ToggleIntercept, "toggle intercept"),
CaptureResponse: binding(cfg.CaptureResponse, "capture response"),
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Edit: binding(cfg.Edit, "edit"),
@@ -36,6 +36,6 @@ func (ic InterceptKeyMap) Bindings() []key.Binding {
ic.Forward, ic.ForwardAll,
ic.Drop, ic.DropAll,
ic.Edit, ic.EditExternal, ic.UndoEdits,
ic.AutoForward, ic.CaptureResponse,
ic.ToggleIntercept, ic.CaptureResponse,
}
}
+1 -21
View File
@@ -2,7 +2,6 @@ package plugins
import (
"log"
"net/url"
"strings"
"time"
@@ -65,26 +64,7 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
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 {
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
reason := L.OptString(1, "plugin requested quit")
select {
case mgr.Quit <- reason:
+2 -2
View File
@@ -36,8 +36,8 @@ func RenderWithTitle(border lipgloss.Style, title, content string, width, height
if fillW < 0 {
fillW = 0
}
topLine := "╭" + label + strings.Repeat("─", fillW) + "╮"
topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine)
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render("╭ ") + bc.Render(title) + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
-7
View File
@@ -22,7 +22,6 @@ import (
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"
)
@@ -64,7 +63,6 @@ type Model struct {
replay replayUI.Model
diff diffUI.Model
docs docsUI.Model
scope scopeUI.Model
pluginsPage pluginsUI.Model
findingsPage findingsUI.Model
copyAs copyasUI.Model
@@ -86,7 +84,6 @@ func New(broker *intercept.Broker, name, path string) Model {
replay: replayUI.New(),
diff: diffUI.New(),
docs: docsUI.New(),
scope: scopeUI.New(name, path),
pluginsPage: pluginsUI.New(mgr),
findingsPage: findingsUI.New(),
copyAs: copyasUI.New(),
@@ -101,10 +98,6 @@ func New(broker *intercept.Broker, name, path string) Model {
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)
-15
View File
@@ -10,7 +10,6 @@ import (
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
@@ -20,7 +19,6 @@ const (
pageHistory page = "History"
pageReplay page = "Replay"
pageDiff page = "Diff"
pageScopes page = "Scopes"
pagePlugins page = "Plugins"
pageFindings page = "Findings"
pageDocs page = "Docs"
@@ -93,19 +91,6 @@ var pageRegistry = []pageEntry{
},
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 },
-10
View File
@@ -20,7 +20,6 @@ import (
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) {
@@ -79,15 +78,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
-1
View File
@@ -19,7 +19,6 @@ var contentMarkdown = strings.Join([]string{
readDoc("proxy.md"),
readDoc("certificate.md"),
readDoc("history.md"),
readDoc("scopes.md"),
}, "\n")
type Model struct {
+2 -2
View File
@@ -29,7 +29,7 @@ type Model struct {
responseCursor int
editing bool
autoForward bool
interceptEnabled bool
pendingEdits map[*intercept.PendingRequest]string
pendingResponseEdits map[*intercept.PendingResponse]string
@@ -60,7 +60,7 @@ func New(broker *intercept.Broker) Model {
return Model{
broker: broker,
autoForward: cfg.Intercept.DefaultAutoForward,
interceptEnabled: cfg.Intercept.DefaultInterceptEnabled,
captureResponse: cfg.Intercept.DefaultCaptureResponse,
listViewport: lv,
responseViewport: rv,
+4 -4
View File
@@ -15,7 +15,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case intercept.RequestArrivedMsg:
if m.autoForward {
if !m.interceptEnabled {
m.broker.Decide(msg.Req, intercept.Forward)
break
}
@@ -152,9 +152,9 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
}
}
case key.Matches(msg, keys.Keys.Intercept.AutoForward):
m.autoForward = !m.autoForward
if m.autoForward {
case key.Matches(msg, keys.Keys.Intercept.ToggleIntercept):
m.interceptEnabled = !m.interceptEnabled
if !m.interceptEnabled {
for len(m.queue) > 0 {
m.applyAndDecide(intercept.Forward)
}
+2 -2
View File
@@ -52,8 +52,8 @@ func (m *Model) renderListPanel(w, h int) string {
)
title := icons.I.Request + "Requests"
if m.autoForward {
title += " [auto forward]"
if !m.interceptEnabled {
title += " " + lipgloss.NewStyle().Foreground(style.S.Error).Render("[intercept off]")
}
return style.RenderWithTitle(border, title, inner, w, h)
}
-150
View File
@@ -1,150 +0,0 @@
package scope
import (
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
const (
fieldNone = -1
fieldWhitelist = 0
fieldBlacklist = 1
)
const (
minTaH = 3
maxTaH = 12
fixedH = 8 // (blank + label + desc + blank) x2
)
type ScopeChangedMsg struct {
Whitelist []string
Blacklist []string
}
type Model struct {
focusIdx int
wlTextarea textarea.Model
blTextarea textarea.Model
innerH int
width int
height int
help help.Model
}
func New(name, path string) Model {
wl := style.NewTextarea(true)
wl.Placeholder = "one pattern per line..."
bl := style.NewTextarea(true)
bl.Placeholder = "one pattern per line..."
bl.Blur()
return Model{
focusIdx: fieldNone,
wlTextarea: wl,
blTextarea: bl,
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m *Model) SetScope(whitelist, blacklist []string) {
m.wlTextarea.SetValue(strings.Join(whitelist, "\n"))
m.blTextarea.SetValue(strings.Join(blacklist, "\n"))
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.syncLayout()
}
func (m *Model) syncLayout() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
panelH := m.height - statusH
m.innerH = max(1, style.PanelContentH(panelH))
taH := (m.innerH - fixedH) / 2
if taH < minTaH {
taH = minTaH
}
if taH > maxTaH {
taH = maxTaH
}
// width - 2 (panel border) - 1 (leading space in view) - 3 (right margin + cursor)
taW := max(1, m.width-6)
m.wlTextarea.SetWidth(taW)
m.wlTextarea.SetHeight(taH)
m.blTextarea.SetWidth(taW)
m.blTextarea.SetHeight(taH)
}
func (m Model) IsEditing() bool {
return m.focusIdx == fieldWhitelist || m.focusIdx == fieldBlacklist
}
func (m *Model) scopeChangedCmd() tea.Cmd {
wl := parseLines(m.wlTextarea.Value())
bl := parseLines(m.blTextarea.Value())
return func() tea.Msg {
return ScopeChangedMsg{Whitelist: wl, Blacklist: bl}
}
}
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
out = append(out, t)
}
}
return out
}
func (m Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(
m.help.View(formKeyMap{focusIdx: m.focusIdx}),
)
}
type formKeyMap struct {
focusIdx int
}
func (k formKeyMap) ShortHelp() []key.Binding {
cycle := keys.Keys.Global.CycleFocus
hlp := keys.Keys.Global.Help
switch k.focusIdx {
case fieldWhitelist, fieldBlacklist:
esc := keys.Keys.Global.Escape
escBinding := key.NewBinding(key.WithKeys(esc.Keys()...), key.WithHelp(esc.Help().Key, "unfocus"))
return []key.Binding{
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line")),
escBinding,
cycle,
}
}
return []key.Binding{cycle, hlp}
}
func (k formKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
-70
View File
@@ -1,70 +0,0 @@
package scope
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
kp, isKey := msg.(tea.KeyPressMsg)
if !isKey {
return m, nil
}
if key.Matches(kp, keys.Keys.Global.CycleFocus) {
return m.cycleFocus()
}
if key.Matches(kp, keys.Keys.Global.Help) && !m.IsEditing() {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
switch m.focusIdx {
case fieldWhitelist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.wlTextarea, cmd = m.wlTextarea.Update(kp)
return m, cmd
case fieldBlacklist:
if key.Matches(kp, keys.Keys.Global.Escape) {
return m.blurAll()
}
var cmd tea.Cmd
m.blTextarea, cmd = m.blTextarea.Update(kp)
return m, cmd
}
return m, nil
}
func (m Model) blurAll() (tea.Model, tea.Cmd) {
m.wlTextarea.Blur()
m.blTextarea.Blur()
m.focusIdx = fieldNone
m.syncLayout()
return m, m.scopeChangedCmd()
}
func (m Model) cycleFocus() (tea.Model, tea.Cmd) {
scopeCmd := m.scopeChangedCmd()
var focusCmd tea.Cmd
switch m.focusIdx {
case fieldNone, fieldBlacklist:
m.blTextarea.Blur()
m.focusIdx = fieldWhitelist
focusCmd = m.wlTextarea.Focus()
case fieldWhitelist:
m.wlTextarea.Blur()
m.focusIdx = fieldBlacklist
focusCmd = m.blTextarea.Focus()
}
m.syncLayout()
return m, tea.Batch(focusCmd, scopeCmd)
}
-84
View File
@@ -1,84 +0,0 @@
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
}