Fix scroll & copy buttons

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-19 20:25:50 +02:00
parent 69d5d0ffec
commit 2c3e19258f
11 changed files with 195 additions and 35 deletions
+2
View File
@@ -17,6 +17,8 @@ type Finding struct {
// UpsertFinding inserts the finding if the (plugin_name, dedup_key) pair does
// not already exist. Returns true when the row was actually inserted.
func (d *DB) UpsertFinding(f Finding) (bool, error) {
d.dedupMu.Lock()
defer d.dedupMu.Unlock()
res, err := d.conn.Exec(
`INSERT OR IGNORE INTO findings (plugin_name, dedup_key, title, description, severity, dismissed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
+10 -8
View File
@@ -187,21 +187,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keys.Keys.Global.CopyAs):
var raw, scheme string
var responseFocused bool
switch m.page {
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
responseFocused = m.intercept.IsResponseFocused()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
}
if raw != "" {
if raw != "" && !responseFocused {
m.copyAs.SetSize(m.width, m.height)
m.copyAs.Open(copyasUI.OpenMsg{RawRequest: raw, Scheme: scheme})
}
@@ -209,23 +210,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, keys.Keys.Global.Copy):
var raw, scheme string
var responseFocused bool
switch m.page {
case pageIntercept:
raw = m.intercept.CurrentRaw()
scheme = m.intercept.CurrentScheme()
case pageDiff:
raw = m.diff.CurrentRaw()
scheme = "https"
responseFocused = m.intercept.IsResponseFocused()
case pageHistory:
raw = m.history.CurrentRaw()
scheme = m.history.CurrentScheme()
responseFocused = m.history.IsResponseFocused()
case pageReplay:
raw = m.replay.CurrentRaw()
scheme = m.replay.CurrentScheme()
responseFocused = m.replay.IsResponseFocused()
}
if raw != "" {
m.copy.SetSize(m.width, m.height)
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme})
m.copy.Open(copyUI.OpenMsg{RawRequest: raw, Scheme: scheme, ShowURL: !responseFocused})
}
return m, nil
+12
View File
@@ -25,6 +25,7 @@ func writeClipboard(text string) {
type OpenMsg struct {
RawRequest string
Scheme string
ShowURL bool
}
type copyItem struct {
@@ -90,6 +91,17 @@ func (m *Model) Open(msg OpenMsg) {
m.rawRequest = msg.RawRequest
m.scheme = msg.Scheme
m.open = true
items := allItems
if !msg.ShowURL {
filtered := make([]list.Item, 0, len(allItems))
for _, it := range allItems {
if it.(copyItem).id != "url" {
filtered = append(filtered, it)
}
}
items = filtered
}
m.list.SetItems(items)
m.list.ResetFilter()
m.list.Select(0)
m.list.SetSize(m.popupInnerWidth(), m.listHeight())
+1 -1
View File
@@ -405,7 +405,7 @@ func (diffKeyMap) ShortHelp() []key.Binding {
func (m diffKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs}
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right}
all := append(keys.Keys.Diff.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
+10
View File
@@ -113,6 +113,14 @@ type FindingsLoadedMsg struct {
}
func (m *Model) refreshBody() {
m.refreshBodyScroll(true)
}
func (m *Model) refreshBodyKeepScroll() {
m.refreshBodyScroll(false)
}
func (m *Model) refreshBodyScroll(reset bool) {
if len(m.findings) == 0 {
m.bodyViewport.SetContent("")
return
@@ -120,8 +128,10 @@ func (m *Model) refreshBody() {
f := m.findings[m.cursor]
rendered := m.renderMarkdownCached(f.Description, m.bodyViewport.Width())
m.bodyViewport.SetContent(rendered)
if reset {
m.bodyViewport.GotoTop()
}
}
func (m *Model) renderMarkdownCached(src string, width int) string {
if src == "" {
+12
View File
@@ -15,6 +15,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("findings load error: %v", msg.Err)
return m, nil
}
var prevID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
prevID = m.findings[m.cursor].ID
}
m.findings = msg.Findings
if m.cursor >= len(m.findings) {
m.cursor = max(0, len(m.findings)-1)
@@ -26,7 +30,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pager.SetTotalPages(len(m.findings))
}
m.refreshListViewport()
var newID int64
if len(m.findings) > 0 && m.cursor < len(m.findings) {
newID = m.findings[m.cursor].ID
}
if newID != prevID {
m.refreshBody()
} else {
m.refreshBodyKeepScroll()
}
return m, nil
case tea.MouseWheelMsg:
+7
View File
@@ -60,9 +60,16 @@ func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw
}
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentScheme() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return "https"
+4
View File
@@ -78,6 +78,10 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.captureResponse && m.focusedPanel == panelResponses
}
func (m Model) CurrentScheme() string {
if len(m.queue) == 0 {
return "https"
+18 -2
View File
@@ -34,10 +34,19 @@ type Entry struct {
Err error
}
type panel int
const (
panelList panel = iota
panelRequest
panelResponse
)
type Model struct {
entries []Entry
cursor int
editing bool
focusedPanel panel
database *db.DB
listViewport viewport.Model
@@ -68,10 +77,17 @@ func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing }
func (m Model) IsResponseFocused() bool {
return m.focusedPanel == panelResponse
}
func (m Model) CurrentRaw() string {
if len(m.entries) == 0 || m.cursor >= len(m.entries) {
return ""
}
if m.focusedPanel == panelResponse {
return m.entries[m.cursor].ResponseRaw
}
return m.entries[m.cursor].RequestRaw
}
@@ -183,12 +199,12 @@ 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, r.Send, r.Edit, g.Help}
return []key.Binding{g.Up, g.Down, g.CycleFocus, r.Send, r.Edit, g.Help}
}
func (m replayKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
pageGlobals := []key.Binding{g.Up, g.Down, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs}
pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Escape, g.Copy, g.CopyAs}
all := append(keys.Keys.Replay.Bindings(), pageGlobals...)
all = append(all, g.CommonBindings()...)
return keys.ChunkByWidth(all, m.width)
+94 -6
View File
@@ -1,6 +1,9 @@
package replay
import (
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"fmt"
"io"
@@ -9,8 +12,11 @@ import (
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/db"
"github.com/anotherhadi/spilltea/internal/keys"
@@ -91,14 +97,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.requestViewport.ScrollLeft(6)
m.responseViewport.ScrollLeft(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - 1)
m.scrollFocusedViewportVertical(-1)
}
case tea.MouseWheelDown:
if msg.Mod.Contains(tea.ModShift) {
m.requestViewport.ScrollRight(6)
m.responseViewport.ScrollRight(6)
} else {
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + 1)
m.scrollFocusedViewportVertical(1)
}
case tea.MouseWheelLeft:
m.requestViewport.ScrollLeft(6)
@@ -124,18 +130,36 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
r := keys.Keys.Replay
switch {
case key.Matches(msg, g.Up):
if m.focusedPanel == panelList {
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.refreshBody()
}
} else {
m.scrollFocusedViewportVertical(-1)
}
case key.Matches(msg, g.Down):
if m.focusedPanel == panelList {
if m.cursor < len(m.entries)-1 {
m.cursor++
m.refreshListViewport()
m.refreshBody()
}
} else {
m.scrollFocusedViewportVertical(1)
}
case key.Matches(msg, g.CycleFocus):
switch m.focusedPanel {
case panelList:
m.focusedPanel = panelRequest
case panelRequest:
m.focusedPanel = panelResponse
default:
m.focusedPanel = panelList
}
case key.Matches(msg, r.Send):
if len(m.entries) > 0 && !m.entries[m.cursor].Sending {
@@ -166,18 +190,22 @@ func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, g.ScrollUp):
step := m.responseViewport.Height() / 2
vp := m.focusedViewport()
step := vp.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() - step)
vp.SetYOffset(vp.YOffset() - step)
m.setFocusedViewport(vp)
case key.Matches(msg, g.ScrollDown):
step := m.responseViewport.Height() / 2
vp := m.focusedViewport()
step := vp.Height() / 2
if step < 1 {
step = 1
}
m.responseViewport.SetYOffset(m.responseViewport.YOffset() + step)
vp.SetYOffset(vp.YOffset() + step)
m.setFocusedViewport(vp)
case key.Matches(msg, g.Left):
m.requestViewport.ScrollLeft(6)
@@ -280,6 +308,29 @@ func (m Model) updateEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// focusedViewport returns the viewport that should receive scroll events.
// When the list is focused, scroll targets the request panel.
func (m *Model) focusedViewport() viewport.Model {
if m.focusedPanel == panelResponse {
return m.responseViewport
}
return m.requestViewport
}
func (m *Model) setFocusedViewport(vp viewport.Model) {
if m.focusedPanel == panelResponse {
m.responseViewport = vp
} else {
m.requestViewport = vp
}
}
func (m *Model) scrollFocusedViewportVertical(delta int) {
vp := m.focusedViewport()
vp.SetYOffset(vp.YOffset() + delta)
m.setFocusedViewport(vp)
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
if len(m.entries) == 0 {
@@ -369,6 +420,14 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
limit := int64(config.Global.App.MaxBodySizeMB) * 1024 * 1024
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, limit))
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
if decoded, decErr := decodeBody(enc, respBody); decErr == nil {
respBody = decoded
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length")
}
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s %d %s\n", resp.Proto, resp.StatusCode, http.StatusText(resp.StatusCode))
for _, line := range util.SortedHeaderLines(resp.Header) {
@@ -380,6 +439,35 @@ func doSend(entry Entry) (responseRaw string, statusCode int, err error) {
return sb.String(), resp.StatusCode, nil
}
func decodeBody(encoding string, body []byte) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "gzip":
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "br":
return io.ReadAll(brotli.NewReader(bytes.NewReader(body)))
case "deflate":
r, err := zlib.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "zstd":
r, err := zstd.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
return nil, fmt.Errorf("unsupported encoding: %s", encoding)
}
func entryToDB(e Entry) db.ReplayEntry {
errMsg := ""
if e.Err != nil {
+11 -4
View File
@@ -34,9 +34,9 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
panelStyle := s.Panel
if !m.editing && m.focusedPanel == panelList {
panelStyle = s.PanelFocused
}
var dots string
if len(m.entries) > 0 {
@@ -58,13 +58,20 @@ func (m *Model) renderRequestPanel(w, h int) string {
border = s.PanelFocused
} else {
body = m.requestViewport.View()
if m.focusedPanel == panelRequest {
border = s.PanelFocused
}
}
return style.RenderWithTitle(border, icons.I.Request+"Request", body, w, h)
}
func (m *Model) renderResponsePanel(w, h int) string {
s := style.S
return style.RenderWithTitle(s.Panel, icons.I.Response+"Response", m.responseViewport.View(), w, h)
border := s.Panel
if !m.editing && m.focusedPanel == panelResponse {
border = s.PanelFocused
}
return style.RenderWithTitle(border, icons.I.Response+"Response", m.responseViewport.View(), w, h)
}
func (m *Model) renderStatusBar() string {