mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 17:52:33 +02:00
385b6e84e0
[37m- New global keybindings: GotoTop (Home), GotoBottom (G/End), PrevPage ([), NextPage (])[0m [37m- Wired in history, findings, and intercept update handlers[0m [37m- Removes duplicate tea.Quit case in intercept/update.go[0m [37mCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>[0m
364 lines
8.6 KiB
Go
364 lines
8.6 KiB
Go
package history
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/anotherhadi/spilltea/internal/db"
|
|
"github.com/anotherhadi/spilltea/internal/keys"
|
|
"github.com/anotherhadi/spilltea/internal/style"
|
|
diffUI "github.com/anotherhadi/spilltea/internal/ui/diff"
|
|
replayUI "github.com/anotherhadi/spilltea/internal/ui/replay"
|
|
"github.com/anotherhadi/spilltea/internal/util"
|
|
)
|
|
|
|
type EntriesLoadedMsg struct {
|
|
Entries []db.Entry
|
|
}
|
|
|
|
func LoadEntriesCmd(database *db.DB) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if database == nil {
|
|
return EntriesLoadedMsg{}
|
|
}
|
|
entries, _ := database.ListEntries()
|
|
return EntriesLoadedMsg{Entries: entries}
|
|
}
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case EntriesLoadedMsg:
|
|
// Ignore background reloads while a search is active (but not during a mode switch reset).
|
|
if m.searchKind != searchKindOff && (m.searchAccepted || m.searchInput.Value() != "") {
|
|
return m, nil
|
|
}
|
|
prevCursor := m.cursor
|
|
m.entries = msg.Entries
|
|
if m.cursor >= len(m.entries) {
|
|
m.cursor = len(m.entries) - 1
|
|
}
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
m.pager.SetTotalPages(len(m.entries))
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
if m.cursor != prevCursor {
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
case SearchResultMsg:
|
|
m.entries = msg.Entries
|
|
m.cursor = 0
|
|
m.searchErr = ""
|
|
m.pager.SetTotalPages(len(m.entries))
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
if m.searchKind == searchKindSQL {
|
|
m.acceptSearch()
|
|
}
|
|
|
|
case SearchErrMsg:
|
|
m.searchErr = msg.Err.Error()
|
|
m.entries = nil
|
|
m.pager.SetTotalPages(0)
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
|
|
case tea.MouseWheelMsg:
|
|
switch msg.Button {
|
|
case tea.MouseWheelUp:
|
|
if msg.Mod.Contains(tea.ModShift) {
|
|
m.bodyViewport.ScrollLeft(6)
|
|
} else {
|
|
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - 1)
|
|
}
|
|
case tea.MouseWheelDown:
|
|
if msg.Mod.Contains(tea.ModShift) {
|
|
m.bodyViewport.ScrollRight(6)
|
|
} else {
|
|
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + 1)
|
|
}
|
|
case tea.MouseWheelLeft:
|
|
m.bodyViewport.ScrollLeft(6)
|
|
case tea.MouseWheelRight:
|
|
m.bodyViewport.ScrollRight(6)
|
|
}
|
|
|
|
case tea.KeyPressMsg:
|
|
h := keys.Keys.History
|
|
g := keys.Keys.Global
|
|
|
|
if m.searchKind != searchKindOff && !m.searchAccepted {
|
|
// Actively typing: only search navigation + accept/cancel.
|
|
switch {
|
|
case key.Matches(msg, g.Escape):
|
|
return m, m.clearSearch()
|
|
|
|
case msg.String() == "enter":
|
|
if m.searchKind == searchKindSQL {
|
|
return m, SQLCmd(m.database, m.searchInput.Value())
|
|
}
|
|
m.acceptSearch()
|
|
|
|
case key.Matches(msg, g.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
case key.Matches(msg, g.Down):
|
|
if m.cursor < len(m.entries)-1 {
|
|
m.cursor++
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
|
if m.searchKind == searchKindFulltext {
|
|
return m, tea.Batch(cmd, SearchCmd(m.database, m.searchInput.Value()))
|
|
}
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if m.searchKind != searchKindOff && m.searchAccepted {
|
|
// Filter accepted: Escape clears, all other shortcuts fall through.
|
|
if key.Matches(msg, g.Escape) {
|
|
return m, m.clearSearch()
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case key.Matches(msg, keys.Keys.History.Filter):
|
|
prev := m.searchKind
|
|
m.searchKind = searchKindFulltext
|
|
m.searchAccepted = false
|
|
m.searchInput.Placeholder = "filter requests..."
|
|
m.searchErr = ""
|
|
m.searchInput.Focus()
|
|
m.recalcSizes()
|
|
if prev != searchKindFulltext {
|
|
m.searchInput.SetValue("")
|
|
return m, LoadEntriesCmd(m.database)
|
|
}
|
|
|
|
case key.Matches(msg, keys.Keys.History.SqlQuery):
|
|
prev := m.searchKind
|
|
m.searchKind = searchKindSQL
|
|
m.searchAccepted = false
|
|
m.searchInput.Placeholder = "status_code = 200 AND host LIKE '%.api.%'"
|
|
m.searchErr = ""
|
|
m.searchInput.Focus()
|
|
m.recalcSizes()
|
|
if prev != searchKindSQL {
|
|
m.searchInput.SetValue("")
|
|
return m, LoadEntriesCmd(m.database)
|
|
}
|
|
|
|
case key.Matches(msg, g.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
case key.Matches(msg, g.Down):
|
|
if m.cursor < len(m.entries)-1 {
|
|
m.cursor++
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
case key.Matches(msg, g.CycleFocus):
|
|
if m.focusedPanel == panelRequest {
|
|
m.focusedPanel = panelResponse
|
|
} else {
|
|
m.focusedPanel = panelRequest
|
|
}
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
|
|
case key.Matches(msg, g.SendToReplay):
|
|
if len(m.entries) > 0 {
|
|
e := m.entries[m.cursor]
|
|
scheme := util.InferScheme(e.Host)
|
|
return m, func() tea.Msg {
|
|
return replayUI.SendToReplayMsg{
|
|
Scheme: scheme,
|
|
Host: e.Host,
|
|
RequestRaw: e.RequestRaw,
|
|
}
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, g.SendToDiff):
|
|
if len(m.entries) > 0 {
|
|
e := m.entries[m.cursor]
|
|
var raw, label string
|
|
if m.focusedPanel == panelResponse {
|
|
raw = e.ResponseRaw
|
|
label = fmt.Sprintf("%d %s", e.StatusCode, http.StatusText(e.StatusCode))
|
|
} else {
|
|
raw = e.RequestRaw
|
|
label = e.Method + " " + e.Host + e.Path
|
|
}
|
|
return m, func() tea.Msg {
|
|
return diffUI.SendToDiffMsg{Label: label, Raw: raw}
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, h.DeleteEntry):
|
|
if len(m.entries) > 0 {
|
|
id := m.entries[m.cursor].ID
|
|
if m.database != nil {
|
|
m.database.DeleteEntry(id)
|
|
}
|
|
return m, LoadEntriesCmd(m.database)
|
|
}
|
|
|
|
case key.Matches(msg, h.DeleteAll):
|
|
if m.database != nil {
|
|
if m.searchKind != searchKindOff {
|
|
for _, e := range m.entries {
|
|
m.database.DeleteEntry(e.ID)
|
|
}
|
|
} else {
|
|
m.database.DeleteAllEntries()
|
|
}
|
|
}
|
|
return m, m.clearSearch()
|
|
|
|
case key.Matches(msg, g.ScrollUp):
|
|
step := m.bodyViewport.Height() / 2
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() - step)
|
|
|
|
case key.Matches(msg, g.ScrollDown):
|
|
step := m.bodyViewport.Height() / 2
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.bodyViewport.SetYOffset(m.bodyViewport.YOffset() + step)
|
|
|
|
case key.Matches(msg, g.Left):
|
|
m.bodyViewport.ScrollLeft(6)
|
|
|
|
case key.Matches(msg, g.Right):
|
|
m.bodyViewport.ScrollRight(6)
|
|
|
|
case key.Matches(msg, g.GotoTop):
|
|
m.cursor = 0
|
|
m.pager.Page = 0
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
|
|
case key.Matches(msg, g.GotoBottom):
|
|
if len(m.entries) > 0 {
|
|
m.cursor = len(m.entries) - 1
|
|
m.pager.Page = m.pager.TotalPages - 1
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
}
|
|
|
|
case key.Matches(msg, g.PrevPage):
|
|
step := m.pager.PerPage
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.cursor -= step
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
|
|
case key.Matches(msg, g.NextPage):
|
|
step := m.pager.PerPage
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.cursor += step
|
|
if m.cursor >= len(m.entries) {
|
|
m.cursor = len(m.entries) - 1
|
|
if m.cursor < 0 {
|
|
m.cursor = 0
|
|
}
|
|
}
|
|
m.refreshListViewport()
|
|
m.refreshBody()
|
|
m.bodyViewport.SetYOffset(0)
|
|
m.bodyViewport.SetXOffset(0)
|
|
|
|
case key.Matches(msg, keys.Keys.Global.Help):
|
|
m.help.ShowAll = !m.help.ShowAll
|
|
m.recalcSizes()
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) refreshListViewport() {
|
|
if m.pager.PerPage > 0 {
|
|
if len(m.entries) == 0 {
|
|
m.pager.Page = 0
|
|
m.pager.TotalPages = 0
|
|
} else {
|
|
m.pager.Page = m.cursor / m.pager.PerPage
|
|
m.pager.SetTotalPages(len(m.entries))
|
|
}
|
|
}
|
|
m.listViewport.SetContent(m.renderList())
|
|
}
|
|
|
|
func (m *Model) refreshBody() {
|
|
if len(m.entries) == 0 {
|
|
m.bodyViewport.SetContent("")
|
|
return
|
|
}
|
|
e := m.entries[m.cursor]
|
|
var raw string
|
|
if m.focusedPanel == panelResponse {
|
|
raw = e.ResponseRaw
|
|
} else {
|
|
raw = e.RequestRaw
|
|
}
|
|
if raw == "" {
|
|
w, h := m.bodyViewport.Width(), m.bodyViewport.Height()
|
|
m.bodyViewport.SetContent(lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, style.S.Faint.Render(util.CenterLines("(˘・_・˘)", "no response stored"))))
|
|
return
|
|
}
|
|
m.bodyViewport.SetContent(style.HighlightHTTP(raw))
|
|
}
|