From ec0fb6109982b0c7829368993fa11f407c813078 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Sat, 30 May 2026 19:57:25 +0200 Subject: [PATCH] add filter in ui/replay Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- internal/config/default_config.yaml | 1 + internal/config/keybindings.go | 1 + internal/keys/replay.go | 4 +- internal/ui/replay/model.go | 86 +++++++++++++++++++++++++-- internal/ui/replay/update.go | 92 ++++++++++++++++++++--------- internal/ui/replay/view.go | 14 ++++- 6 files changed, 165 insertions(+), 33 deletions(-) diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 71a201f..cdbe9fe 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -82,6 +82,7 @@ keybindings: undo_edits: "u,ctrl+z" delete_entry: "x" delete_all: "X" + filter: "/" diff: clear: "x" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 8d2fa73..03fd8ad 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -56,6 +56,7 @@ type ReplayKeys struct { UndoEdits string `mapstructure:"undo_edits"` Delete string `mapstructure:"delete_entry"` DeleteAll string `mapstructure:"delete_all"` + Filter string `mapstructure:"filter"` } type DiffKeys struct { diff --git a/internal/keys/replay.go b/internal/keys/replay.go index 7643eb8..f88c520 100644 --- a/internal/keys/replay.go +++ b/internal/keys/replay.go @@ -12,6 +12,7 @@ type ReplayKeyMap struct { UndoEdits key.Binding Delete key.Binding DeleteAll key.Binding + Filter key.Binding } func newReplayKeyMap(cfg config.ReplayKeys) ReplayKeyMap { @@ -22,9 +23,10 @@ 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"), } } 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.Delete, r.DeleteAll, r.Filter} } diff --git a/internal/ui/replay/model.go b/internal/ui/replay/model.go index f5b529e..35d76ec 100644 --- a/internal/ui/replay/model.go +++ b/internal/ui/replay/model.go @@ -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" @@ -43,12 +45,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 +71,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 +86,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 +122,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 @@ -148,6 +160,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 +207,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.Filter, g.Help} } func (m replayKeyMap) FullHelp() [][]key.Binding { diff --git a/internal/ui/replay/update.go b/internal/ui/replay/update.go index f11c798..847a825 100644 --- a/internal/ui/replay/update.go +++ b/internal/ui/replay/update.go @@ -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): @@ -217,19 +243,19 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { 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): @@ -238,11 +264,14 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { log.Printf("replay: delete all entries: %v", err) } } - m.entries = nil + 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 +312,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() diff --git a/internal/ui/replay/view.go b/internal/ui/replay/view.go index 2aeb154..7d32e8b 100644 --- a/internal/ui/replay/view.go +++ b/internal/ui/replay/view.go @@ -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 {