6 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
21 changed files with 344 additions and 99 deletions
+4
View File
@@ -82,12 +82,16 @@ keybindings:
undo_edits: "u,ctrl+z"
delete_entry: "x"
delete_all: "X"
filter: "/"
flag: "m"
diff:
clear: "x"
findings:
dismiss: "x"
dismiss_all: "X"
flag: "m"
plugins:
toggle: "space"
+5 -1
View File
@@ -56,6 +56,8 @@ type ReplayKeys struct {
UndoEdits string `mapstructure:"undo_edits"`
Delete string `mapstructure:"delete_entry"`
DeleteAll string `mapstructure:"delete_all"`
Filter string `mapstructure:"filter"`
Flag string `mapstructure:"flag"`
}
type DiffKeys struct {
@@ -63,7 +65,9 @@ type DiffKeys struct {
}
type FindingsKeys struct {
Dismiss string `mapstructure:"dismiss"`
Dismiss string `mapstructure:"dismiss"`
DismissAll string `mapstructure:"dismiss_all"`
Flag string `mapstructure:"flag"`
}
type PluginsKeys struct {
+3 -1
View File
@@ -79,7 +79,8 @@ CREATE TABLE IF NOT EXISTS replay_entries (
request_raw TEXT NOT NULL,
response_raw TEXT 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -89,6 +90,7 @@ CREATE TABLE IF NOT EXISTS findings (
description TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
dismissed INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE(plugin_name, dedup_key)
);
+25 -2
View File
@@ -11,6 +11,7 @@ type Finding struct {
Title string
Description string
Severity string
Flagged bool
CreatedAt time.Time
}
@@ -34,7 +35,7 @@ func (d *DB) UpsertFinding(f Finding) (bool, error) {
func (d *DB) LoadFindings() ([]Finding, error) {
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`,
)
if err != nil {
@@ -45,9 +46,11 @@ func (d *DB) LoadFindings() ([]Finding, error) {
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 {
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
}
f.Flagged = flagged != 0
for _, layout := range findingTimeFormats {
if t, err := time.Parse(layout, ts); err == nil {
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)
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
StatusCode int
ErrorMsg string
Flagged bool
}
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO replay_entries (timestamp, scheme, host, path, method, original_raw, request_raw, response_raw, status_code, error_msg, flagged)
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,
e.StatusCode, e.ErrorMsg, e.Flagged,
)
if err != nil {
return 0, err
@@ -36,15 +37,15 @@ func (d *DB) InsertReplayEntry(e ReplayEntry) (int64, error) {
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,
`UPDATE replay_entries SET request_raw=?, response_raw=?, status_code=?, error_msg=?, flagged=? WHERE id=?`,
e.RequestRaw, e.ResponseRaw, e.StatusCode, e.ErrorMsg, e.Flagged, 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
`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`,
)
if err != nil {
@@ -56,8 +57,9 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
for rows.Next() {
var e ReplayEntry
var ts string
var flagged int
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
}
var tsErr error
@@ -65,11 +67,17 @@ func (d *DB) ListReplayEntries() ([]ReplayEntry, error) {
if tsErr != nil {
log.Printf("db: parse replay timestamp %q: %v", ts, tsErr)
}
e.Flagged = flagged != 0
entries = append(entries, e)
}
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 {
_, err := d.conn.Exec(`DELETE FROM replay_entries WHERE id = ?`, id)
return err
@@ -79,3 +87,18 @@ func (d *DB) DeleteAllReplayEntries() error {
_, err := d.conn.Exec(`DELETE FROM replay_entries`)
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: "󰈻 ",
}
} else {
I = &Icons{}
I = &Icons{
Flag: "*",
}
}
}
+3
View File
@@ -14,6 +14,9 @@ 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)
if r.URL.Host != "" {
fmt.Fprintf(&sb, "Host: %s\n", r.URL.Host)
}
for _, line := range util.SortedHeaderLines(r.Header) {
sb.WriteString(line)
}
+7 -3
View File
@@ -6,15 +6,19 @@ import (
)
type FindingsKeyMap struct {
Dismiss key.Binding
Dismiss key.Binding
DismissAll key.Binding
Flag key.Binding
}
func newFindingsKeyMap(cfg config.FindingsKeys) 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 {
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
Delete key.Binding
DeleteAll key.Binding
Filter key.Binding
Flag key.Binding
}
func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
@@ -22,9 +24,11 @@ func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap {
UndoEdits: binding(cfg.UndoEdits, "undo edits"),
Delete: binding(cfg.Delete, "delete"),
DeleteAll: binding(cfg.DeleteAll, "delete all"),
Filter: binding(cfg.Filter, "filter"),
Flag: binding(cfg.Flag, "flag"),
}
}
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:
if e.searching {
switch {
case key.Matches(msg, d.SearchReset):
case key.Matches(msg, g.Escape):
e.searching = false
e.searchInput.Blur()
e.searchInput.SetValue("")
e.matches = nil
e.matchIndex = 0
e.SetSize(e.width, e.height)
case msg.String() == "enter":
e.searching = false
+1 -1
View File
@@ -186,7 +186,7 @@ type findingsKeyMap struct{ width int }
func (findingsKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
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 {
+16
View File
@@ -72,6 +72,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshListViewport()
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):
if len(m.findings) > 0 && m.database != 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)
}
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):
util.ScrollViewport(&m.bodyViewport, -1)
case key.Matches(msg, g.ScrollDown):
+12 -1
View File
@@ -66,9 +66,10 @@ func (m *Model) renderList() string {
sevStyle := style.SeverityStyle(f.Severity)
sevLabel := sevStyle.Width(8).Render(f.Severity)
ts := f.CreatedAt.Format("15:04:05")
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
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
if titleW < 0 {
titleW = 0
@@ -79,8 +80,13 @@ func (m *Model) renderList() string {
var line string
if selected {
bg := lipgloss.NewStyle().Background(ilovetui.S.Selection)
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
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),
bg.Width(1).Render(""),
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),
)
} else {
flagStr := " "
if f.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
flagSt.Width(2).Render(flagStr),
sevLabel,
" ",
pluginStr,
+16 -21
View File
@@ -76,14 +76,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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.cursor = 0
if selectedID != 0 {
for i, e := range m.entries {
if e.ID == selectedID {
m.cursor = i
break
}
}
}
m.searchErr = ""
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
if selectedID == 0 {
m.bodyViewport.SetYOffset(0)
m.bodyViewport.SetXOffset(0)
}
if m.searchKind == searchKindSQL {
m.acceptSearch()
}
@@ -105,7 +119,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
@@ -116,24 +129,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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)
-6
View File
@@ -120,9 +120,6 @@ func (m *Model) renderList() string {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(ilovetui.S.Primary).Width(2).Render(">"),
@@ -139,9 +136,6 @@ func (m *Model) renderList() string {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
if icons.I.Flag == "" {
flagStr = "★ "
}
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
+6
View File
@@ -28,8 +28,14 @@ func parseRawRequest(content string, req *intercept.PendingRequest) {
if parsed.Proto != "" {
r.Proto = parsed.Proto
}
if parsed.Host != "" {
r.URL.Host = parsed.Host
}
r.Header = make(http.Header)
for _, h := range parsed.Headers {
if strings.EqualFold(h.Key, "host") {
continue
}
r.Header.Set(h.Key, h.Value)
}
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) {
onResponses := m.captureResponse && m.focusedPanel == panelResponses
switch {
case key.Matches(msg, keys.Keys.Global.Escape):
m.saveCurrentEdit()
@@ -296,19 +294,6 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg, cmds *[]tea.Cmd) (tea.Model,
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(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:
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
+84 -4
View File
@@ -2,11 +2,13 @@ package replay
import (
"fmt"
"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"
ilovetui "github.com/anotherhadi/ilovetui"
@@ -31,6 +33,7 @@ type Entry struct {
ResponseRaw string // filled after send
StatusCode int // 0 = not sent yet
Sending bool
Flagged bool
Err error
}
@@ -43,12 +46,18 @@ const (
)
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
editing bool
focusedPanel panel
database *db.DB
filterInput textinput.Model
filterActive bool
filterAccepted bool
listViewport viewport.Model
requestViewport viewport.Model
responseViewport viewport.Model
@@ -63,11 +72,14 @@ type Model struct {
func New() Model {
ta := ilovetui.NewTextarea(false)
ta.Blur()
ti := textinput.New()
ti.Prompt = ""
return Model{
listViewport: ilovetui.NewViewport(),
requestViewport: ilovetui.NewViewport(),
responseViewport: ilovetui.NewViewport(),
textarea: ta,
filterInput: ti,
pager: ilovetui.NewPaginator(),
help: ilovetui.NewHelp(),
}
@@ -75,7 +87,7 @@ func New() Model {
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 {
return m.focusedPanel == panelResponse
@@ -111,8 +123,9 @@ func (m *Model) SetDB(d *db.DB) {
return
}
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))
if len(m.entries) > 0 {
m.cursor = len(m.entries) - 1
@@ -136,6 +149,7 @@ func entryFromDB(dbe db.ReplayEntry) Entry {
RequestRaw: dbe.RequestRaw,
ResponseRaw: dbe.ResponseRaw,
StatusCode: dbe.StatusCode,
Flagged: dbe.Flagged,
Err: err,
}
}
@@ -148,6 +162,7 @@ func (m *Model) SetSize(w, h int) {
func (m *Model) recalcSizes() {
m.help.SetWidth(m.width - 2)
m.filterInput.SetWidth(m.width - 4)
listH, bodyH := ilovetui.SplitH(m.height, m.renderStatusBar(), 0.35)
@@ -194,12 +209,77 @@ func (m *Model) bodyHalfWidths() (left, right int) {
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 }
func (replayKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
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 {
+97 -28
View File
@@ -67,15 +67,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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()
m.allEntries = append(m.allEntries, entry)
m.applyFilter(len(m.allEntries) - 1)
case sentMsg:
if msg.index >= 0 && msg.index < len(m.entries) {
e := &m.entries[msg.index]
allIdx := msg.index
if allIdx >= 0 && allIdx < len(m.allEntries) {
e := m.allEntries[allIdx]
e.Sending = false
e.StatusCode = msg.statusCode
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()
}
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)
}
}
m.allEntries[allIdx] = e
}
m.refreshListViewport()
m.refreshBody()
prevAllIdx := m.currentAllIdx()
m.applyFilter(prevAllIdx)
case util.EditorFinishedMsg:
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) {
g := keys.Keys.Global
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 {
case key.Matches(msg, g.Up):
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):
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)
allIdx := m.currentAllIdx()
m.allEntries[allIdx].Sending = true
m.allEntries[allIdx].ResponseRaw = ""
m.allEntries[allIdx].Err = nil
entry := m.allEntries[allIdx]
m.applyFilter(allIdx)
return m, sendCmd(entry, allIdx)
}
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.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):
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 err := m.database.DeleteReplayEntry(e.DBID); err != nil {
log.Printf("replay: delete entry: %v", err)
}
}
m.entries = append(m.entries[:m.cursor], m.entries[m.cursor+1:]...)
if m.cursor >= len(m.entries) && m.cursor > 0 {
m.cursor--
m.allEntries = append(m.allEntries[:allIdx], m.allEntries[allIdx+1:]...)
preferAllIdx := allIdx
if preferAllIdx >= len(m.allEntries) {
preferAllIdx = len(m.allEntries) - 1
}
m.pager.SetTotalPages(len(m.entries))
m.refreshListViewport()
m.refreshBody()
m.applyFilter(preferAllIdx)
}
case key.Matches(msg, r.DeleteAll):
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)
}
}
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.pager.SetTotalPages(0)
m.refreshListViewport()
m.refreshBody()
m.filterActive = false
m.filterAccepted = false
m.filterInput.SetValue("")
m.filterInput.Blur()
m.recalcSizes()
m.applyFilter(-1)
case key.Matches(msg, keys.Keys.Global.GotoTop):
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):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
@@ -513,6 +581,7 @@ func entryToDB(e Entry) db.ReplayEntry {
ResponseRaw: e.ResponseRaw,
StatusCode: e.StatusCode,
ErrorMsg: errMsg,
Flagged: e.Flagged,
}
}
+25 -2
View File
@@ -8,6 +8,7 @@ import (
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
"github.com/anotherhadi/spilltea/internal/util"
)
@@ -73,7 +74,18 @@ func (m *Model) renderResponsePanel(w, h int) 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 {
@@ -94,19 +106,25 @@ func (m *Model) renderList() string {
selBg := ilovetui.S.Selection
w := m.listViewport.Width()
const fixedW = 2 + 7 + 1 + 3 + 1
const fixedW = 2 + 2 + 7 + 1 + 3 + 1
hostPathW := w - fixedW
if hostPathW < 0 {
hostPathW = 0
}
statusStr, statusSt := entryStatus(e)
flagSt := lipgloss.NewStyle().Foreground(ilovetui.S.Primary)
var line string
if selected {
bg := lipgloss.NewStyle().Background(selBg)
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
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),
bg.Width(1).Render(""),
statusSt.Background(selBg).Render(statusStr),
@@ -114,8 +132,13 @@ func (m *Model) renderList() string {
bg.Bold(true).Width(hostPathW).Render(e.Host+e.Path),
)
} else {
flagStr := " "
if e.Flagged {
flagStr = icons.I.Flag + " "
}
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
flagSt.Width(2).Render(flagStr),
style.S.Method(e.Method).Render(e.Method),
" ",
statusSt.Render(statusStr),
+1 -1
View File
@@ -4,7 +4,7 @@
}: let
browser = import ./browser.nix {inherit pkgs;};
pname = "spilltea";
version = "0.0.7";
version = "0.0.8";
ldflags = ["-s" "-w" "-X main.version=${version}"];
pkg = buildGoApplication {
inherit pname version ldflags;