7 Commits

Author SHA1 Message Date
Hadi a75b65644f v0.0.8
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:18:31 +02:00
Hadi cc0176a73d feat: add "flag" toggle in replay & findings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 20:15:51 +02:00
Hadi ec0fb61099 add filter in ui/replay
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:57:25 +02:00
Hadi ee3e6806c7 fix: jumping cursor on tick when filter is set
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:30:22 +02:00
Hadi 36eab35576 feat: add "Host" header
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:24:40 +02:00
Hadi 8211d87379 fix: overlaping keys in textarea
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 19:10:33 +02:00
Hadi ef1789af2f edit default keybindings
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
2026-05-30 11:56:39 +02:00
21 changed files with 351 additions and 106 deletions
+11 -7
View File
@@ -38,7 +38,7 @@ keybindings:
toggle_sidebar: "ctrl+b" toggle_sidebar: "ctrl+b"
cycle_focus: "tab" cycle_focus: "tab"
send_to_replay: "ctrl+r" send_to_replay: "ctrl+r"
send_to_diff: "ctrl+d" send_to_diff: "ctrl+x"
copy_as: "ctrl+y" copy_as: "ctrl+y"
copy: "y" copy: "y"
up: "up,k" up: "up,k"
@@ -47,10 +47,10 @@ keybindings:
right: "right,l" right: "right,l"
goto_top: "g" goto_top: "g"
goto_bottom: "G,end" goto_bottom: "G,end"
scroll_up: "pgup" scroll_up: "pgup,ctrl+u"
scroll_down: "pgdown" scroll_down: "pgdown,ctrl+d"
prev_page: "[" prev_page: "[,ctrl+i"
next_page: "]" next_page: "],ctrl+o"
intercept: intercept:
toggle_intercept: "i" toggle_intercept: "i"
@@ -61,7 +61,7 @@ keybindings:
drop_all: "D" drop_all: "D"
edit: "e,enter" edit: "e,enter"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z" undo_edits: "u,ctrl+z"
history: history:
delete_entry: "x" delete_entry: "x"
@@ -79,15 +79,19 @@ keybindings:
send: "s, enter" send: "s, enter"
edit: "e" edit: "e"
edit_external: "E" edit_external: "E"
undo_edits: "ctrl+z" undo_edits: "u,ctrl+z"
delete_entry: "x" delete_entry: "x"
delete_all: "X" delete_all: "X"
filter: "/"
flag: "m"
diff: diff:
clear: "x" clear: "x"
findings: findings:
dismiss: "x" dismiss: "x"
dismiss_all: "X"
flag: "m"
plugins: plugins:
toggle: "space" toggle: "space"
+4
View File
@@ -56,6 +56,8 @@ type ReplayKeys struct {
UndoEdits string `mapstructure:"undo_edits"` UndoEdits string `mapstructure:"undo_edits"`
Delete string `mapstructure:"delete_entry"` Delete string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"` DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"`
Flag string `mapstructure:"flag"`
} }
type DiffKeys struct { type DiffKeys struct {
@@ -64,6 +66,8 @@ type DiffKeys struct {
type FindingsKeys struct { type FindingsKeys struct {
Dismiss string `mapstructure:"dismiss"` Dismiss string `mapstructure:"dismiss"`
DismissAll string `mapstructure:"dismiss_all"`
Flag string `mapstructure:"flag"`
} }
type PluginsKeys struct { type PluginsKeys struct {
+3 -1
View File
@@ -79,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries (
request_raw TEXT NOT NULL, request_raw TEXT NOT NULL,
response_raw TEXT NOT NULL, response_raw TEXT NOT NULL,
status_code INTEGER NOT NULL, status_code INTEGER NOT NULL,
error_msg TEXT NOT NULL error_msg TEXT NOT NULL,
flagged INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS findings ( CREATE TABLE IF NOT EXISTS findings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -89,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info', severity TEXT NOT NULL DEFAULT 'info',
dismissed INTEGER NOT NULL DEFAULT 0, dismissed INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key) UNIQUE(plugin_name, dedup_key)
); );
+25 -2
View File
@@ -11,6 +11,7 @@ type Finding struct {
Title string Title string
Description string Description string
Severity string Severity string
Flagged bool
CreatedAt time.Time CreatedAt time.Time
} }
@@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) { func (d *DB) LoadFindings() ([]Finding, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, plugin_name, dedup_key, title, description, severity, created_at `SELECT id, plugin_name, dedup_key, title, description, severity, flagged, created_at
FROM findings WHERE dismissed = 0 ORDER BY id ASC`, FROM findings WHERE dismissed = 0 ORDER BY id ASC`,
) )
if err != nil { if err != nil {
@@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
for rows.Next() { for rows.Next() {
var f Finding var f Finding
var ts string var ts string
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &ts); err != nil { var flagged int
if err := rows.Scan(&f.ID, &f.PluginName, &f.DedupKey, &f.Title, &f.Description, &f.Severity, &flagged, &ts); err != nil {
return nil, err return nil, err
} }
f.Flagged = flagged != 0
for _, layout := range findingTimeFormats { for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil { if t, err := time.Parse(layout, ts); err == nil {
f.CreatedAt = t.Local() f.CreatedAt = t.Local()
@@ -63,3 +66,23 @@ func (d *DB) DismissFinding(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id) _, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE id = ?`, id)
return err return err
} }
func (d *DB) ToggleFindingFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE findings SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
// DismissAllFindings dismisses unflagged findings first. If none are unflagged
// (only flagged ones remain), it dismisses everything.
func (d *DB) DismissAllFindings() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM findings WHERE dismissed = 0 AND flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1 WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`UPDATE findings SET dismissed = 1`)
return err
}
+30 -7
View File
@@ -17,16 +17,17 @@ type ReplayEntry struct {
ResponseRaw string ResponseRaw string
StatusCode int StatusCode int
ErrorMsg string ErrorMsg string
Flagged bool
} }
func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) { func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
res, err := d.conn.Exec( res, err := d.conn.Exec(
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg) `INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Timestamp.UTC().Format(time.RFC3339), e.Timestamp.UTC().Format(time.RFC3339),
e.Scheme, e.Host, e.Path, e.Method, e.Scheme, e.Host, e.Path, e.Method,
e.OriginalRaw, e.RequestRaw, e.ResponseRaw, e.OriginalRaw, e.RequestRaw, e.ResponseRaw,
e.StatusCode, e.ErrorMsg, e.StatusCode, e.ErrorMsg, e.Flagged,
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -36,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
func (d *DB) UpdateReplayEntry(e ReplayEntry) error { func (d *DB) UpdateReplayEntry(e ReplayEntry) error {
_, err := d.conn.Exec( _, err := d.conn.Exec(
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=? WHERE id=?`, `UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.ID, e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, e.ID,
) )
return err return err
} }
func (d *DB) ListReplayEntries() ([]ReplayEntry, error) { func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
rows, err := d.conn.Query( rows, err := d.conn.Query(
`SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg `SELECT id, timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged
FROM replay_entries ORDER BY id ASC`, FROM replay_entries ORDER BY id ASC`,
) )
if err != nil { if err != nil {
@@ -56,8 +57,9 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
for rows.Next() { for rows.Next() {
var e ReplayEntry var e ReplayEntry
var ts string var ts string
var flagged int
if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method, if err := rows.Scan(&e.ID, &ts, &e.Scheme, &e.Host, &e.Path, &e.Method,
&e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg); err != nil { &e.OriginalRaw, &e.RequestRaw, &e.ResponseRaw, &e.StatusCode, &e.ErrorMsg, &flagged); err != nil {
return nil, err return nil, err
} }
var tsErr error var tsErr error
@@ -65,11 +67,17 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
if tsErr != nil { if tsErr != nil {
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr) log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
} }
e.Flagged = flagged != 0
entries = append(entries, e) entries = append(entries, e)
} }
return entries, rows.Err() return entries, rows.Err()
} }
func (d *DB) ToggleReplayFlag(id int64) error {
_, err := d.conn.Exec(`UPDATE replay_entries SET flagged = NOT flagged WHERE id = ?`, id)
return err
}
func (d *DB) DeleteReplayEntry(id int64) error { func (d *DB) DeleteReplayEntry(id int64) error {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id) _, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
return err return err
@@ -79,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
_, err := d.conn.Exec(`DELETE FROM replay_entries`) _, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err return err
} }
// DeleteAllReplayEntriesExceptFlagged deletes all unflagged replay entries. If
// none are unflagged (only flagged ones remain), it deletes everything.
func (d *DB) DeleteAllReplayEntriesExceptFlagged() error {
var count int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM replay_entries WHERE flagged = 0`).Scan(&count); err != nil {
return err
}
if count > 0 {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE flagged = 0`)
return err
}
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
return err
}
+3 -1
View File
@@ -48,6 +48,8 @@ func Init(cfg *config.Config) {
Flag: "󰈻 ", Flag: "󰈻 ",
} }
} else { } else {
I = &Icons{} I = &Icons{
Flag: "*",
}
} }
} }
+3
View File
@@ -14,6 +14,9 @@ func FormatRawRequest(f *proxy.Flow) string {
r := f.Request r := f.Request
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto) fmt.Fprintf(&sb, "%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
if r.URL.Host != "" {
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
}
for _, line := range util.SortedHeaderLines(r.Header) { for _, line := range util.SortedHeaderLines(r.Header) {
sb.WriteString(line) sb.WriteString(line)
} }
+5 -1
View File
@@ -7,14 +7,18 @@ import (
type FindingsKeyMap struct { type FindingsKeyMap struct {
Dismiss key.Binding Dismiss key.Binding
DismissAll key.Binding
Flag key.Binding
} }
func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap { func newFindingsKeyMap(cfg config.FindingsKeys) FindingsKeyMap {
return FindingsKeyMap{ return FindingsKeyMap{
Dismiss: binding(cfg.Dismiss, "dismiss"), Dismiss: binding(cfg.Dismiss, "dismiss"),
DismissAll: binding(cfg.DismissAll, "dismiss all"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (f FindingsKeyMap) Bindings() []key.Binding { func (f FindingsKeyMap) Bindings() []key.Binding {
return []key.Binding{f.Dismiss} return []key.Binding{f.Flag, f.Dismiss, f.DismissAll}
} }
+5 -1
View File
@@ -12,6 +12,8 @@ type ReplayKeyMap struct {
UndoEdits key.Binding UndoEdits key.Binding
Delete key.Binding Delete key.Binding
DeleteAll key.Binding DeleteAll key.Binding
Filter key.Binding
Flag key.Binding
} }
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
UndoEdits: binding(cfg.UndoEdits, "undo edits"), UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Delete: binding(cfg.Delete, "delete"), Delete: binding(cfg.Delete, "delete"),
DeleteAll: binding(cfg.DeleteAll, "delete all"), DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"),
Flag: binding(cfg.Flag, "flag"),
} }
} }
func (r ReplayKeyMap) Bindings() []key.Binding { func (r ReplayKeyMap) Bindings() []key.Binding {
return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Delete, r.DeleteAll} return []key.Binding{r.Send, r.Edit, r.EditExt, r.UndoEdits, r.Flag, r.Delete, r.DeleteAll, r.Filter}
} }
+1 -4
View File
@@ -18,12 +18,9 @@ func (e Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg: case tea.KeyPressMsg:
if e.searching { if e.searching {
switch { switch {
case key.Matches(msg, d.SearchReset): case key.Matches(msg, g.Escape):
e.searching = false e.searching = false
e.searchInput.Blur() e.searchInput.Blur()
e.searchInput.SetValue("")
e.matches = nil
e.matchIndex = 0
e.SetSize(e.width, e.height) e.SetSize(e.width, e.height)
case msg.String() == "enter": case msg.String() == "enter":
e.searching = false e.searching = false
+1 -1
View File
@@ -186,7 +186,7 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding { func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
f := keys.Keys.Findings f := keys.Keys.Findings
return []key.Binding{g.Up, g.Down, f.Dismiss, g.Copy, g.Help} return []key.Binding{g.Up, g.Down, f.Flag, f.Dismiss, f.DismissAll, g.Copy, g.Help}
} }
func (m findingsKeyMap) FullHelp() [][]key.Binding { func (m findingsKeyMap) FullHelp() [][]key.Binding {
+16
View File
@@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
} }
case key.Matches(msg, f.Flag):
if len(m.findings) > 0 && m.database != nil {
if err := m.database.ToggleFindingFlag(m.findings[m.cursor].ID); err != nil {
log.Printf("findings: toggle flag: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, f.Dismiss): case key.Matches(msg, f.Dismiss):
if len(m.findings) > 0 && m.database != nil { if len(m.findings) > 0 && m.database != nil {
if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil { if err := m.database.DismissFinding(m.findings[m.cursor].ID); err != nil {
@@ -80,6 +88,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, RefreshCmd(m.database) return m, RefreshCmd(m.database)
} }
case key.Matches(msg, f.DismissAll):
if m.database != nil {
if err := m.database.DismissAllFindings(); err != nil {
log.Printf("findings: dismiss all: %v", err)
return m, nil
}
return m, RefreshCmd(m.database)
}
case key.Matches(msg, g.ScrollUp): case key.Matches(msg, g.ScrollUp):
util.ScrollViewport(&m.bodyViewport, -1) util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown): case key.Matches(msg, g.ScrollDown):
+12 -1
View File
@@ -66,9 +66,10 @@ func (m *Model) renderList() string {
sevStyle := style.SeverityStyle(f.Severity) sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity) sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05") ts := f.CreatedAt.Format("15:04:05")
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
w := m.listViewport.Width() w := m.listViewport.Width()
const fixedW = 2 + 8 + 1 + 8 + 1 + 10 + 1 const fixedW = 2 + 2 + 8 + 1 + 8 + 1 + 10 + 1
titleW := w - fixedW titleW := w - fixedW
if titleW < 0 { if titleW < 0 {
titleW = 0 titleW = 0
@@ -79,8 +80,13 @@ func (m *Model) renderList() string {
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection) bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity), sevStyle.Background(ilovetui.S.Selection).Width(8).Render(f.Severity),
bg.Width(1).Render(""), bg.Width(1).Render(""),
bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)), bg.Foreground(ilovetui.S.Subtle).Width(8).Render(util.Truncate(f.PluginName, 8)),
@@ -90,8 +96,13 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(titleW).Render(f.Title), bg.Bold(true).Width(titleW).Render(f.Title),
) )
} else { } else {
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
sevLabel, sevLabel,
" ", " ",
pluginStr, pluginStr,
+14 -19
View File
@@ -76,14 +76,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case SearchResultMsg: case SearchResultMsg:
var selectedID int64
if m.cursor >= 0 && m.cursor < len(m.entries) {
selectedID = m.entries[m.cursor].ID
}
m.entries = msg.Entries m.entries = msg.Entries
m.cursor = 0 m.cursor = 0
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
break
}
}
}
m.searchErr = "" m.searchErr = ""
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport() m.refreshListViewport()
m.refreshBody() m.refreshBody()
if selectedID == 0 {
m.bodyViewport.SetYOffset(0) m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0) m.bodyViewport.SetXOffset(0)
}
if m.searchKind == searchKindSQL { if m.searchKind == searchKindSQL {
m.acceptSearch() m.acceptSearch()
} }
@@ -105,7 +119,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global g := keys.Keys.Global
if m.searchKind != searchKindOff && !m.searchAccepted { if m.searchKind != searchKindOff && !m.searchAccepted {
// Actively typing: only search navigation + accept/cancel.
switch { switch {
case key.Matches(msg, g.Escape): case key.Matches(msg, g.Escape):
return m, m.clearSearch() return m, m.clearSearch()
@@ -116,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.acceptSearch() m.acceptSearch()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg) m.searchInput, cmd = m.searchInput.Update(msg)
-6
View File
@@ -120,9 +120,6 @@ func (m *Model) renderList() string {
flagStr := " " flagStr := " "
if e.Flagged { if e.Flagged {
flagStr = icons.I.Flag + " " flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
} }
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
@@ -139,9 +136,6 @@ func (m *Model) renderList() string {
flagStr := " " flagStr := " "
if e.Flagged { if e.Flagged {
flagStr = icons.I.Flag + " " flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
} }
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
+6
View File
@@ -28,8 +28,14 @@ func parseRawRequest(content string, req *intercept.PendingRequest) {
if parsed.Proto != "" { if parsed.Proto != "" {
r.Proto = parsed.Proto r.Proto = parsed.Proto
} }
if parsed.Host != "" {
r.URL.Host = parsed.Host
}
r.Header = make(http.Header) r.Header = make(http.Header)
for _, h := range parsed.Headers { for _, h := range parsed.Headers {
if strings.EqualFold(h.Key, "host") {
continue
}
r.Header.Set(h.Key, h.Value) r.Header.Set(h.Key, h.Value)
} }
if parsed.Body != "" { if parsed.Body != "" {
-15
View File
@@ -287,8 +287,6 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model
} }
func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) { func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model, tea.Cmd) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch { switch {
case key.Matches(msg, keys.Keys.Global.Escape): case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit() m.saveCurrentEdit()
@@ -296,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
m.textarea.Blur() m.textarea.Blur()
m.refreshBodyViewport() m.refreshBodyViewport()
case key.Matches(msg, keys.Keys.Intercept.UndoEdits):
if onResponses {
if len(m.responseQueue) > 0 {
delete(m.pendingResponseEdits, m.responseQueue[m.responseCursor])
m.textarea.SetValue(intercept.FormatRawResponse(m.responseQueue[m.responseCursor].Flow))
}
} else {
if len(m.queue) > 0 {
delete(m.pendingEdits, m.queue[m.cursor])
m.textarea.SetValue(intercept.FormatRawRequest(m.queue[m.cursor].Flow))
}
}
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg) m.textarea, cmd = m.textarea.Update(msg)
+84 -4
View File
@@ -2,11 +2,13 @@ package replay
import ( import (
"fmt" "fmt"
"strings"
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator" "charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport" "charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
@@ -31,6 +33,7 @@ type Entry struct {
ResponseRaw string // filled after send ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet StatusCode int // 0 = not sent yet
Sending bool Sending bool
Flagged bool
Err error Err error
} }
@@ -43,12 +46,18 @@ const (
) )
type Model struct { type Model struct {
entries []Entry allEntries []Entry
entries []Entry // filtered view; equals allEntries when no filter
entryIndices []int // maps entries[i] -> allEntries[i]; nil when no filter
cursor int cursor int
editing bool editing bool
focusedPanel panel focusedPanel panel
database *db.DB database *db.DB
filterInput textinput.Model
filterActive bool
filterAccepted bool
listViewport viewport.Model listViewport viewport.Model
requestViewport viewport.Model requestViewport viewport.Model
responseViewport viewport.Model responseViewport viewport.Model
@@ -63,11 +72,14 @@ type Model struct {
func New() Model { func New() Model {
ta := ilovetui.NewTextarea(false) ta := ilovetui.NewTextarea(false)
ta.Blur() ta.Blur()
ti := textinput.New()
ti.Prompt = ""
return Model{ return Model{
listViewport: ilovetui.NewViewport(), listViewport: ilovetui.NewViewport(),
requestViewport: ilovetui.NewViewport(), requestViewport: ilovetui.NewViewport(),
responseViewport: ilovetui.NewViewport(), responseViewport: ilovetui.NewViewport(),
textarea: ta, textarea: ta,
filterInput: ti,
pager: ilovetui.NewPaginator(), pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(), help: ilovetui.NewHelp(),
} }
@@ -75,7 +87,7 @@ func New() Model {
func (m Model) Init() tea.Cmd { return nil } func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing } func (m Model) IsEditing() bool { return m.editing || (m.filterActive && !m.filterAccepted) }
func (m Model) IsResponseFocused() bool { func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse return m.focusedPanel == panelResponse
@@ -111,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
return return
} }
for _, dbe := range entries { for _, dbe := range entries {
m.entries = append(m.entries, entryFromDB(dbe)) m.allEntries = append(m.allEntries, entryFromDB(dbe))
} }
m.entries = m.allEntries
m.pager.SetTotalPages(len(m.entries)) m.pager.SetTotalPages(len(m.entries))
if len(m.entries) > 0 { if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1 m.cursor = len(m.entries) - 1
@@ -136,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
RequestRaw: dbe.RequestRaw, RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw, ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode, StatusCode: dbe.StatusCode,
Flagged: dbe.Flagged,
Err: err, Err: err,
} }
} }
@@ -148,6 +162,7 @@ func (m *Model) SetSize(w, h int) {
func (m *Model) recalcSizes() { func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2) m.help.SetWidth(m.width - 2)
m.filterInput.SetWidth(m.width - 4)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35) listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
@@ -194,12 +209,77 @@ func (m *Model) bodyHalfWidths() (left, right int) {
return return
} }
func (m *Model) currentAllIdx() int {
if m.entryIndices != nil && m.cursor < len(m.entryIndices) {
return m.entryIndices[m.cursor]
}
if m.cursor < len(m.allEntries) {
return m.cursor
}
return -1
}
func matchesFilter(e Entry, term string) bool {
return strings.Contains(strings.ToLower(e.Host), term) ||
strings.Contains(strings.ToLower(e.Path), term) ||
strings.Contains(strings.ToLower(strings.TrimSpace(e.Method)), term)
}
func (m *Model) applyFilter(preferAllIdx int) {
term := strings.ToLower(m.filterInput.Value())
if !m.filterActive || term == "" {
m.entries = m.allEntries
m.entryIndices = nil
if preferAllIdx >= 0 && preferAllIdx < len(m.allEntries) {
m.cursor = preferAllIdx
}
} else {
m.entries = nil
m.entryIndices = nil
newCursor := 0
found := false
for i, e := range m.allEntries {
if matchesFilter(e, term) {
if i == preferAllIdx && !found {
newCursor = len(m.entries)
found = true
}
m.entries = append(m.entries, e)
m.entryIndices = append(m.entryIndices, i)
}
}
if found {
m.cursor = newCursor
} else {
m.cursor = 0
}
}
if len(m.entries) == 0 {
m.cursor = 0
} else if m.cursor >= len(m.entries) {
m.cursor = len(m.entries) - 1
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
}
func (m *Model) clearFilter() {
prevAllIdx := m.currentAllIdx()
m.filterActive = false
m.filterAccepted = false
m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(prevAllIdx)
}
type replayKeyMap struct{ width int } type replayKeyMap struct{ width int }
func (replayKeyMap) ShortHelp() []key.Binding { func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help} return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, r.Flag, r.Filter, g.Help}
} }
func (m replayKeyMap) FullHelp() [][]key.Binding { func (m replayKeyMap) FullHelp() [][]key.Binding {
+97 -28
View File
@@ -67,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
entry.DBID = id entry.DBID = id
} }
} }
m.entries = append(m.entries, entry) m.allEntries = append(m.allEntries, entry)
m.cursor = len(m.entries) - 1 m.applyFilter(len(m.allEntries) - 1)
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
case sentMsg: case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) { allIdx := msg.index
e := &m.entries[msg.index] if allIdx >= 0 && allIdx < len(m.allEntries) {
e := m.allEntries[allIdx]
e.Sending = false e.Sending = false
e.StatusCode = msg.statusCode e.StatusCode = msg.statusCode
e.ResponseRaw = msg.responseRaw e.ResponseRaw = msg.responseRaw
@@ -84,13 +82,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
e.ResponseRaw = "Error: " + msg.err.Error() e.ResponseRaw = "Error: " + msg.err.Error()
} }
if m.database != nil && e.DBID != 0 { if m.database != nil && e.DBID != 0 {
if err := m.database.UpdateReplayEntry(entryToDB(*e)); err != nil { if err := m.database.UpdateReplayEntry(entryToDB(e)); err != nil {
log.Printf("replay: update entry: %v", err) log.Printf("replay: update entry: %v", err)
} }
} }
m.allEntries[allIdx] = e
} }
m.refreshListViewport() prevAllIdx := m.currentAllIdx()
m.refreshBody() m.applyFilter(prevAllIdx)
case util.EditorFinishedMsg: case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 { if msg.Err == nil && msg.Content != "" && len(m.entries) > 0 {
@@ -133,6 +132,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
g := keys.Keys.Global g := keys.Keys.Global
r := keys.Keys.Replay r := keys.Keys.Replay
if m.filterActive && !m.filterAccepted {
switch {
case key.Matches(msg, g.Escape):
m.clearFilter()
case msg.String() == "enter":
m.filterAccepted = true
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
prevAllIdx := m.currentAllIdx()
m.applyFilter(prevAllIdx)
return m, cmd
}
return m, nil
}
if m.filterActive && m.filterAccepted {
if key.Matches(msg, g.Escape) {
m.clearFilter()
return m, nil
}
}
switch { switch {
case key.Matches(msg, g.Up): case key.Matches(msg, g.Up):
if m.focusedPanel == panelList { if m.focusedPanel == panelList {
@@ -168,12 +193,13 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, r.Send): case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending { if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
m.entries[m.cursor].Sending = true allIdx := m.currentAllIdx()
m.entries[m.cursor].ResponseRaw = "" m.allEntries[allIdx].Sending = true
m.entries[m.cursor].Err = nil m.allEntries[allIdx].ResponseRaw = ""
m.refreshListViewport() m.allEntries[allIdx].Err = nil
m.refreshBody() entry := m.allEntries[allIdx]
return m, sendCmd(m.entries[m.cursor], m.cursor) m.applyFilter(allIdx)
return m, sendCmd(entry, allIdx)
} }
case key.Matches(msg, r.Edit): case key.Matches(msg, r.Edit):
@@ -215,34 +241,67 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollRight(6) m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6) m.responseViewport.ScrollRight(6)
case key.Matches(msg, r.Flag):
if len(m.entries) > 0 {
allIdx := m.currentAllIdx()
if m.database != nil && m.allEntries[allIdx].DBID != 0 {
if err := m.database.ToggleReplayFlag(m.allEntries[allIdx].DBID); err != nil {
log.Printf("replay: toggle flag: %v", err)
}
}
m.allEntries[allIdx].Flagged = !m.allEntries[allIdx].Flagged
m.applyFilter(allIdx)
}
case key.Matches(msg, r.Delete): case key.Matches(msg, r.Delete):
if len(m.entries) > 0 { if len(m.entries) > 0 {
e := m.entries[m.cursor] allIdx := m.currentAllIdx()
e := m.allEntries[allIdx]
if m.database != nil && e.DBID != 0 { if m.database != nil && e.DBID != 0 {
if err := m.database.DeleteReplayEntry(e.DBID); err != nil { if err := m.database.DeleteReplayEntry(e.DBID); err != nil {
log.Printf("replay: delete entry: %v", err) log.Printf("replay: delete entry: %v", err)
} }
} }
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...) m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 { preferAllIdx := allIdx
m.cursor-- if preferAllIdx >= len(m.allEntries) {
preferAllIdx = len(m.allEntries) - 1
} }
m.pager.SetTotalPages(len(m.entries)) m.applyFilter(preferAllIdx)
m.refreshListViewport()
m.refreshBody()
} }
case key.Matches(msg, r.DeleteAll): case key.Matches(msg, r.DeleteAll):
if m.database != nil { if m.database != nil {
if err := m.database.DeleteAllReplayEntries(); err != nil { if err := m.database.DeleteAllReplayEntriesExceptFlagged(); err != nil {
log.Printf("replay: delete all entries: %v", err) log.Printf("replay: delete all entries: %v", err)
} }
} }
m.entries = nil // Mirror the DB logic in memory: delete unflagged first; if none, delete all.
hasUnflagged := false
for _, e := range m.allEntries {
if !e.Flagged {
hasUnflagged = true
break
}
}
if hasUnflagged {
filtered := m.allEntries[:0]
for _, e := range m.allEntries {
if e.Flagged {
filtered = append(filtered, e)
}
}
m.allEntries = filtered
} else {
m.allEntries = nil
}
m.cursor = 0 m.cursor = 0
m.pager.SetTotalPages(0) m.filterActive = false
m.refreshListViewport() m.filterAccepted = false
m.refreshBody() m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(-1)
case key.Matches(msg, keys.Keys.Global.GotoTop): case key.Matches(msg, keys.Keys.Global.GotoTop):
m.cursor = 0 m.cursor = 0
@@ -283,6 +342,15 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
} }
} }
case key.Matches(msg, r.Filter):
if m.focusedPanel == panelList {
m.filterActive = true
m.filterAccepted = false
m.filterInput.Placeholder = "filter requests..."
m.filterInput.Focus()
m.recalcSizes()
}
case key.Matches(msg, g.Help): case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
m.recalcSizes() m.recalcSizes()
@@ -513,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry {
ResponseRaw: e.ResponseRaw, ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode, StatusCode: e.StatusCode,
ErrorMsg: errMsg, ErrorMsg: errMsg,
Flagged: e.Flagged,
} }
} }
+25 -2
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons" "github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util" "github.com/anotherhadi/spilltea/internal/util"
) )
@@ -73,7 +74,18 @@ func (m *Model) renderResponsePanel(w, h int) string {
} }
func (m *Model) renderStatusBar() string { func (m *Model) renderStatusBar() string {
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(replayKeyMap{width: m.width})) pad := lipgloss.NewStyle().Padding(0, 1)
if m.filterActive {
filterKey := keys.Keys.Replay.Filter.Help().Key
escKey := keys.Keys.Global.Escape.Help().Key
if m.filterAccepted {
accent := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + ilovetui.S.Bold.Render(m.filterInput.Value()) + ilovetui.S.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(replayKeyMap{width: m.width})))
}
return pad.Render(ilovetui.S.Faint.Render(filterKey) + " " + m.filterInput.View())
}
return pad.Render(m.help.View(replayKeyMap{width: m.width}))
} }
func (m *Model) renderList() string { func (m *Model) renderList() string {
@@ -94,19 +106,25 @@ func (m *Model) renderList() string {
selBg := ilovetui.S.Selection selBg := ilovetui.S.Selection
w := m.listViewport.Width() w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1 const fixedW = 2 + 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW hostPathW := w - fixedW
if hostPathW < 0 { if hostPathW < 0 {
hostPathW = 0 hostPathW = 0
} }
statusStr, statusSt := entryStatus(e) statusStr, statusSt := entryStatus(e)
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string var line string
if selected { if selected {
bg := lipgloss.NewStyle().Background(selBg) bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"), bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
bg.Foreground(ilovetui.S.Primary).Width(2).Render(flagStr),
style.S.Method(e.Method).Background(selBg).Render(e.Method), style.S.Method(e.Method).Background(selBg).Render(e.Method),
bg.Width(1).Render(""), bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr), statusSt.Background(selBg).Render(statusStr),
@@ -114,8 +132,13 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path), bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
) )
} else { } else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top, line = lipgloss.JoinHorizontal(lipgloss.Top,
" ", " ",
flagSt.Width(2).Render(flagStr),
style.S.Method(e.Method).Render(e.Method), style.S.Method(e.Method).Render(e.Method),
" ", " ",
statusSt.Render(statusStr), statusSt.Render(statusStr),
+1 -1
View File
@@ -4,7 +4,7 @@
}: let }: let
browser = import ./browser.nix {inherit pkgs;}; browser = import ./browser.nix {inherit pkgs;};
pname = "spilltea"; pname = "spilltea";
version = "0.0.7"; version = "0.0.8";
ldflags = ["-s" "-w" "-X main.version=${version}"]; ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication { pkg = buildGoApplication {
inherit pname version ldflags; inherit pname version ldflags;